Compare commits

...
31 Commits
Author SHA1 Message Date
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
Yuri Karamian 43f15962f7 Revert "chore(release): prepare v1.3.5"
This reverts commit ade146f9f3.
2026-08-17 17:03:01 +02:00
Yuri Karamian ade146f9f3 chore(release): prepare v1.3.5 2026-08-17 16:50:14 +02:00
Yuri Karamian b253ec4121 fix(paths): preserve cross-platform asset paths 2026-08-17 15:58:20 +02:00
41 changed files with 2827 additions and 1913 deletions
+9 -1
View File
@@ -166,7 +166,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
### Prerequisites ### Prerequisites
- [Rust](https://rustup.rs/) (1.77+) - [Rust](https://rustup.rs/) (1.88+)
- [Node.js](https://nodejs.org/) (18+) - [Node.js](https://nodejs.org/) (18+)
- [pnpm](https://pnpm.io/) - [pnpm](https://pnpm.io/)
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) - System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
@@ -178,6 +178,14 @@ pnpm install
pnpm tauri dev 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 ### Production Build
```bash ```bash
+14 -10
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.3.4", "version": "1.3.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -11,6 +11,10 @@
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "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": "tauri",
"tauri:dev": "tauri dev", "tauri:dev": "tauri dev",
"tauri:build": "tauri build" "tauri:build": "tauri build"
@@ -18,22 +22,22 @@
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2.10.0", "@tauri-apps/cli": "^2.10.0",
"svelte": "^5.49.2", "@types/node": "26.2.0",
"svelte": "^5.56.9",
"svelte-check": "^4.3.6", "svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.3.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1" "vite": "^7.3.6"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.10.1", "@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-updater": "^2.10.0", "@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.19.0", "@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0", "@tiptap/extension-code-block-lowlight": "^3.19.0",
@@ -62,11 +66,11 @@
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.3.0",
"markdown-it-mark": "^4.0.0", "markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0", "markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0", "markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1", "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: allowBuilds:
esbuild: true 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -138,12 +127,6 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -346,18 +329,6 @@ dependencies = [
"crunchy", "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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -389,29 +360,6 @@ dependencies = [
"piper", "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]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@@ -439,40 +387,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 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]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.0" version = "1.25.0"
@@ -624,6 +538,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -738,6 +663,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -768,9 +702,9 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-epoch"
version = "0.9.18" version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
@@ -1314,12 +1248,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "futf" name = "futf"
version = "0.1.5" version = "0.1.5"
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1796,9 +1725,6 @@ name = "hashbrown"
version = "0.12.3" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -1806,7 +1732,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash",
"allocator-api2", "allocator-api2",
] ]
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.3.4" version = "1.3.5"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1862,7 +1788,7 @@ dependencies = [
"notify", "notify",
"objc2-app-kit", "objc2-app-kit",
"png 0.17.16", "png 0.17.16",
"quick-xml 0.36.2", "quick-xml",
"rayon", "rayon",
"regex", "regex",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2554,9 +2480,6 @@ name = "log"
version = "0.4.29" version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "lru" name = "lru"
@@ -2575,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lz4_flex" name = "lz4_flex"
version = "0.11.5" version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]] [[package]]
name = "mac" name = "mac"
@@ -3417,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"quick-xml 0.38.4", "quick-xml",
"serde", "serde",
"time", "time",
] ]
@@ -3576,26 +3499,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "pure-rust-locales" name = "pure-rust-locales"
version = "0.8.2" version = "0.8.2"
@@ -3619,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.36.2" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -3657,14 +3551,15 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.13" version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.4.1",
"lru-slab", "lru-slab",
"rand 0.9.2", "rand 0.10.2",
"rand_pcg 0.10.2",
"ring", "ring",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"rustls", "rustls",
@@ -3705,12 +3600,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.7.3" version = "0.7.3"
@@ -3722,7 +3611,7 @@ dependencies = [
"rand_chacha 0.2.2", "rand_chacha 0.2.2",
"rand_core 0.5.1", "rand_core 0.5.1",
"rand_hc", "rand_hc",
"rand_pcg", "rand_pcg 0.2.1",
] ]
[[package]] [[package]]
@@ -3738,12 +3627,13 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "chacha20",
"rand_core 0.9.5", "getrandom 0.4.1",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -3766,16 +3656,6 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@@ -3796,12 +3676,9 @@ dependencies = [
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_distr" name = "rand_distr"
@@ -3831,6 +3708,15 @@ dependencies = [
"rand_core 0.5.1", "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]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3935,15 +3821,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@@ -4063,35 +3940,6 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rust-stemmers" name = "rust-stemmers"
version = "1.2.0" version = "1.2.0"
@@ -4102,22 +3950,6 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -4231,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -4327,12 +4159,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -4574,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -4600,12 +4426,6 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"
@@ -5007,17 +4827,11 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.44" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -5214,12 +5028,11 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "2.8.0" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"byte-unit",
"fern", "fern",
"log", "log",
"objc2", "objc2",
@@ -5964,12 +5777,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -5988,12 +5795,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -6246,12 +6047,12 @@ dependencies = [
[[package]] [[package]]
name = "wayland-scanner" name = "wayland-scanner"
version = "0.31.8" version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml 0.38.4", "quick-xml",
"quote", "quote",
] ]
@@ -7007,15 +6808,6 @@ dependencies = [
"x11-dl", "x11-dl",
] ]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]] [[package]]
name = "x11" name = "x11"
version = "2.21.0" version = "2.21.0"
+10 -3
View File
@@ -1,11 +1,15 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.3.4" version = "1.3.5"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" 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] [lib]
name = "app_lib" name = "app_lib"
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2" 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"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3" futures = "0.3"
rayon = "1" rayon = "1"
quick-xml = "0.36" quick-xml = "0.41"
urlencoding = "2" urlencoding = "2"
sha2 = "0.10" sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+1 -27
View File
@@ -24,32 +24,6 @@
"dialog:allow-message", "dialog:allow-message",
"dialog:allow-ask", "dialog:allow-ask",
"dialog:allow-confirm", "dialog:allow-confirm",
"fs:default", "fs:allow-read-file"
"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"
] ]
} }
+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 OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434"; const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
#[allow(clippy::too_many_arguments)]
pub fn ai_request( pub fn ai_request(
app: AppHandle, app: AppHandle,
provider: String, provider: String,
@@ -22,7 +23,11 @@ pub fn ai_request(
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
// Handle all API keys as optional; ollama and v1 completions doesnt always require it. // 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() { let result = match provider.as_str() {
"openai" => { "openai" => {
stream_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`). /// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
fn normalize_openai_base(base: &str) -> String { fn normalize_openai_base(base: &str) -> String {
let b = base.trim().trim_end_matches('/'); 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( async fn stream_anthropic(
@@ -268,9 +276,7 @@ async fn stream_openai(
body["temperature"] = json!(0.7); body["temperature"] = json!(0.7);
} }
let mut req = client let mut req = client.post(url).header("content-type", "application/json");
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key { if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key)); req = req.header("Authorization", format!("Bearer {}", key));
@@ -380,7 +386,11 @@ pub async fn test_connection(
model: &str, model: &str,
base_url: Option<&str>, base_url: Option<&str>,
) -> Result<String, String> { ) -> 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 { match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await, "openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => { "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> { async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new(); let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5"); let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" }; let token_key = if is_gpt5 {
"max_completion_tokens"
} else {
"max_tokens"
};
let body = json!({ let body = json!({
"model": model, "model": model,
token_key: 20, token_key: 20,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"content": "Hi" "content": "Hi"
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
] ]
}); });
let mut req = client let mut req = client.post(url).header("content-type", "application/json");
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key { if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", 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 entry = entry.map_err(|e| e.to_string())?;
let path = entry.path(); 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 let filename = path
.file_name() .file_name()
.unwrap_or_default() .unwrap_or_default()
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
Ok(entries) Ok(entries)
} }
/// Restore a backup by extracting the zip over the vault directory fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> { let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
let vault = Path::new(vault_path); let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
let backup = Path::new(backup_path); if backup.parent() != Some(backup_dir.as_path())
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
if !backup.exists() { || !backup.is_file()
return Err("Backup file does not exist".to_string()); {
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 file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
let mut archive = let mut archive =
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
} }
/// Delete a single backup file /// Delete a single backup file
pub fn delete_backup(backup_path: &str) -> Result<(), String> { pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
let path = Path::new(backup_path); let path = validated_backup_file(backup_dir, backup_path)?;
if path.exists() { fs::remove_file(path).map_err(|error| error.to_string())
fs::remove_file(path).map_err(|e| e.to_string())?;
}
Ok(())
} }
/// Remove old backups keeping only the newest `max_count` /// 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 { if backups.len() as u32 > max_count {
let to_remove = backups.split_off(max_count as usize); let to_remove = backups.split_off(max_count as usize);
for entry in to_remove { for entry in to_remove {
delete_backup(&entry.path)?; delete_backup(backup_dir, &entry.path)?;
} }
} }
Ok(()) 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::fs;
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use crate::types::VersionEntry; 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>/ /// Directory: .helixnotes/history/<note-id>/
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf { fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
Path::new(vault_path) Ok(Path::new(vault_path)
.join(".helixnotes") .join(".helixnotes")
.join("history") .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. /// Save a version snapshot if enough time has passed since the last one.
/// Minimum interval: 5 minutes between snapshots. /// Minimum interval: 5 minutes between snapshots.
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) { 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) // Check if we should create a snapshot (5 min cooldown)
if let Ok(entries) = fs::read_dir(&dir) { 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. /// Force-create a version snapshot, bypassing the cooldown.
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) { pub fn force_snapshot(
let dir = history_dir(vault_path, note_id); vault_path: &str,
note_id: &str,
if let Err(e) = fs::create_dir_all(&dir) { raw_content: &str,
eprintln!("Failed to create history dir: {}", e); max_versions: u32,
return; ) -> 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 timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let filename = format!("{}.md", timestamp); let filename = format!("{}.md", timestamp);
let path = dir.join(&filename); let path = dir.join(&filename);
if let Err(e) = fs::write(&path, raw_content) { fs::write(&path, raw_content).map_err(|error| error.to_string())?;
eprintln!("Failed to write version snapshot: {}", e);
return;
}
if max_versions > 0 { 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. /// List all version snapshots for a note, newest first.
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> { 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() { if !dir.exists() {
return Ok(Vec::new()); 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())? { for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?; let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path(); 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 let filename = path
.file_stem() .file_stem()
.unwrap_or_default() .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. /// Get the raw content of a specific version.
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> { 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 // 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 filename = if let Some(t_pos) = timestamp.find('T') {
let date_part = &timestamp[..t_pos]; let date_part = &timestamp[..t_pos];
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z'); let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
let time_dashes = time_part.replace(':', "-"); let time_dashes = time_part.replace(':', "-");
format!("{}.md", format!("{}T{}", date_part, time_dashes)) format!("{date_part}T{time_dashes}.md")
} else { } else {
format!("{}.md", timestamp) 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)) 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())? .map_err(|e| e.to_string())?
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.map(|e| e.path()) .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(); .collect();
// Sort by name (timestamps sort lexicographically) - newest last // Sort by name (timestamps sort lexicographically) - newest last
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
Ok(()) 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 ai;
mod asset_scope;
mod backup; mod backup;
mod commands; mod commands;
mod history; mod history;
mod image_proxy;
mod search; mod search;
mod state; mod state;
mod sync; mod sync;
@@ -11,6 +13,7 @@ mod vault;
use state::AppState; use state::AppState;
#[allow(unused_imports)] #[allow(unused_imports)]
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)] #[cfg(desktop)]
use tauri::{ use tauri::{
@@ -56,6 +59,16 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.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| { .on_page_load(|webview, payload| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if webview.label() == "main" 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)] #[cfg(desktop)]
if show_tray { if show_tray {
setup_tray(app)?; setup_tray(app)?;
@@ -112,6 +139,14 @@ pub fn run() {
std::env::current_dir().unwrap_or_default().join(&path) std::env::current_dir().unwrap_or_default().join(&path)
}; };
if let Some(resolved_str) = resolved.to_str() { 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 = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| { let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string()); *p = Some(resolved_str.to_string());
@@ -137,6 +172,7 @@ pub fn run() {
commands::export_custom_theme, commands::export_custom_theme,
commands::import_custom_themes, commands::import_custom_themes,
commands::set_font_size, commands::set_font_size,
commands::set_scroll_to_change_font_size,
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
commands::set_ui_scale, commands::set_ui_scale,
@@ -190,6 +226,7 @@ pub fn run() {
commands::trash_orphaned_attachments, commands::trash_orphaned_attachments,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::reveal_file,
commands::open_url, commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::write_bytes_to, commands::write_bytes_to,
@@ -217,72 +254,21 @@ pub fn run() {
std::thread::spawn(move || { std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path); let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded); let external_url = percent_decode(encoded);
let response = match image_proxy::fetch(&external_url) {
if !external_url.starts_with("http://") && !external_url.starts_with("https://") { Ok(image) => tauri::http::Response::builder()
let _ = responder.respond( .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() tauri::http::Response::builder()
.status(400) .status(error.status())
.body(Vec::new()) .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;
} }
}; };
responder.respond(response.expect("valid image proxy response"));
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(),
);
}
}
}); });
}); });
@@ -309,6 +295,13 @@ pub fn run() {
std::path::Path::new(&cwd).join(path) std::path::Path::new(&cwd).join(path)
}; };
if let Some(resolved_str) = resolved.to_str() { 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()); let _ = app.emit("open-file", resolved_str.to_string());
} }
} }
@@ -350,9 +343,9 @@ pub fn run() {
let _ = window.hide(); let _ = window.hide();
} }
} }
tauri::WindowEvent::Destroyed => { tauri::WindowEvent::Destroyed
// When main window is destroyed, close all note windows // When main window is destroyed, close all note windows
if window.label() == "main" { if window.label() == "main" => {
let app = window.app_handle(); let app = window.app_handle();
for (label, win) in app.webview_windows() { for (label, win) in app.webview_windows() {
if label.starts_with("note-") { 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")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { 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}; use sha2::{Digest, Sha256};
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(vault_path.as_bytes()); 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)) 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). // before the writer is created, so both indexing and querying use it).
index.tokenizers().register( index.tokenizers().register(
"cjk", "cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(), TextAnalyzer::builder(CjkTokenizer)
.filter(LowerCaser)
.build(),
); );
#[cfg(mobile)] #[cfg(mobile)]
@@ -290,7 +295,7 @@ impl SearchIndex {
doc.add_text(self.path_field, &path_str); doc.add_text(self.path_field, &path_str);
doc.add_text(self.title_field, &meta.title); doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content); 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); let _ = writer.add_document(doc);
} }
} }
@@ -321,7 +326,7 @@ impl SearchIndex {
doc.add_text(self.path_field, path); doc.add_text(self.path_field, path);
doc.add_text(self.title_field, &meta.title); doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content); 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); let _ = writer.add_document(doc);
writer.commit().map_err(|e| e.to_string())?; 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 reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); 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 // 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. // 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.) // (For pure-ASCII queries this yields the same lowercased word tokens as before.)
@@ -376,9 +381,10 @@ impl SearchIndex {
)); ));
vec![(Occur::Should, exact)] vec![(Occur::Should, exact)]
} else { } else {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new( let prefix: Box<dyn Query> =
vec![Term::from_field_text(field, term)], Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
)); field, term,
)]));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new( let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term), Term::from_field_text(field, term),
1, 1,
+31 -8
View File
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
} }
fn normalize_etag(s: &str) -> 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. /// Percent-encode each path segment, keeping the `/` separators.
@@ -265,7 +268,11 @@ impl WebdavClient {
.send() .send()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !resp.status().is_success() { 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()) Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
} }
@@ -279,7 +286,11 @@ impl WebdavClient {
.send() .send()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !resp.status().is_success() { 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 Ok(resp
.headers() .headers()
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
} }
Ok(Event::Text(e)) => { Ok(Event::Text(e)) => {
if cap != Cap::None { if cap != Cap::None {
if let Ok(t) = e.unescape() { if let Ok(decoded) = e.decode() {
buf.push_str(&t); if let Ok(text) = quick_xml::escape::unescape(&decoded) {
buf.push_str(&text);
}
} }
} }
} }
@@ -515,8 +528,14 @@ fn apply_changes(
match (l, r) { match (l, r) {
(Some(lf), Some(re)) => { (Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash); let local_changed = match m {
let remote_changed = m.map_or(true, |me| &me.remote_etag != re); 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 { if !local_changed && !remote_changed {
new_m.files.insert( new_m.files.insert(
key.clone(), 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 /// Run a full sync. Mutes the file watcher while applying local writes, then
/// rebuilds the search index. Returns a summary of what changed. /// 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; use std::sync::atomic::Ordering;
let state = app.state::<AppState>(); let state = app.state::<AppState>();
+19 -4
View File
@@ -124,6 +124,8 @@ pub struct AppConfig {
pub accent_color: Option<String>, pub accent_color: Option<String>,
#[serde(default)] #[serde(default)]
pub font_size: Option<u32>, pub font_size: Option<u32>,
#[serde(default = "default_true")]
pub scroll_to_change_font_size: bool,
#[serde(default)] #[serde(default)]
pub font_family: Option<String>, pub font_family: Option<String>,
#[serde(default)] #[serde(default)]
@@ -296,6 +298,7 @@ impl Default for AppConfig {
system_dark_theme: default_system_dark_theme(), system_dark_theme: default_system_dark_theme(),
accent_color: None, accent_color: None,
font_size: None, font_size: None,
scroll_to_change_font_size: true,
font_family: None, font_family: None,
line_height: None, line_height: None,
ui_scale: None, ui_scale: None,
@@ -549,12 +552,24 @@ mod startup_view_tests {
assert!(!config.show_note_switcher); assert!(!config.show_note_switcher);
let mut value = serde_json::to_value(config).unwrap(); let mut value = serde_json::to_value(config).unwrap();
value value.as_object_mut().unwrap().remove("show_note_switcher");
.as_object_mut()
.unwrap()
.remove("show_note_switcher");
let config: AppConfig = serde_json::from_value(value).unwrap(); let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(!config.show_note_switcher); 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 meta.tags
.iter() .iter()
.map(|t| format!("{}", t)) .map(|t| t.to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
) )
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
chars.next(); // skip '[' chars.next(); // skip '['
let mut depth = 1; let mut depth = 1;
// Skip alt text // Skip alt text
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '[' { if c == '[' {
depth += 1; depth += 1;
} }
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') { if chars.peek() == Some(&'(') {
chars.next(); chars.next();
let mut depth = 1; let mut depth = 1;
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '(' { if c == '(' {
depth += 1; depth += 1;
} }
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if ch == '[' { if ch == '[' {
let mut link_text = String::new(); let mut link_text = String::new();
let mut depth = 1; let mut depth = 1;
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '[' { if c == '[' {
depth += 1; depth += 1;
} }
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') { if chars.peek() == Some(&'(') {
chars.next(); chars.next();
let mut depth = 1; let mut depth = 1;
while let Some(c) = chars.next() { for c in chars.by_ref() {
if c == '(' { if c == '(' {
depth += 1; 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" "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
); );
if is_embeddable { 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; result.links_converted += 1;
format!("![{}]({})", alt, link_target) format!("![{}]({})", alt, link_target)
} else { } else {
let display = if alt_param.is_empty() { let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
file_part.rsplit('/').next().unwrap_or(file_part)
} else if is_dimension_spec(alt_param) {
file_part.rsplit('/').next().unwrap_or(file_part) file_part.rsplit('/').next().unwrap_or(file_part)
} else { } else {
alt_param alt_param
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
let (file_part, anchor) = split_anchor(note_ref); let (file_part, anchor) = split_anchor(note_ref);
if file_part.is_empty() { if file_part.is_empty() {
if let Some(a) = anchor { 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; result.links_converted += 1;
return format!("[{}](#{})", display, a); return format!("[{}](#{})", display, a);
} }
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
.to_string(); .to_string();
content = after_links; content = after_links;
content = fix_md_image_refs(&content, &md_img_re, vault, note_dir, &file_index, &mut result.links_converted); content = fix_md_image_refs(
content = fix_md_link_refs(&content, &md_link_re, vault, note_dir, &file_index, &mut result.links_converted); &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 { if result.links_converted > links_before {
changed = true; changed = true;
@@ -231,10 +251,8 @@ fn rename_deprecated_properties(raw: &str) -> String {
mapping.insert(new_key, val); mapping.insert(new_key, val);
renamed = true; renamed = true;
} }
} else { } else if mapping.remove(&old_key).is_some() {
if mapping.remove(&old_key).is_some() { renamed = true;
renamed = true;
}
} }
} }
@@ -267,7 +285,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
let tags = normalize_tags(&mapping); let tags = normalize_tags(&mapping);
let title = mapping let title = mapping
.get(&serde_yaml::Value::String("title".into())) .get(serde_yaml::Value::String("title".into()))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| { .unwrap_or_else(|| {
@@ -283,7 +301,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
}); });
let id = mapping let id = mapping
.get(&serde_yaml::Value::String("id".into())) .get(serde_yaml::Value::String("id".into()))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@@ -293,9 +311,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter() .iter()
.find_map(|key| { .find_map(|key| {
mapping mapping
.get(&serde_yaml::Value::String((*key).into())) .get(serde_yaml::Value::String((*key).into()))
.and_then(|v| v.as_str()) .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)) .or_else(|| file_created(path))
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
@@ -304,15 +322,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
.iter() .iter()
.find_map(|key| { .find_map(|key| {
mapping mapping
.get(&serde_yaml::Value::String((*key).into())) .get(serde_yaml::Value::String((*key).into()))
.and_then(|v| v.as_str()) .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)) .or_else(|| file_modified(path))
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
let pinned = mapping let pinned = mapping
.get(&serde_yaml::Value::String("pinned".into())) .get(serde_yaml::Value::String("pinned".into()))
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
@@ -332,7 +350,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for key in &["tags", "tag"] { 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) { for raw in yaml_value_to_strings(val) {
let cleaned = raw.trim().trim_start_matches('#').trim().to_string(); let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) { if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
@@ -389,9 +407,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok() .ok()
.and_then(|m| m.created().ok()) .and_then(|m| m.created().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| { .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
} }
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> { fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
@@ -399,9 +415,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
.ok() .ok()
.and_then(|m| m.modified().ok()) .and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| { .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
})
} }
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String { 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 display = &caps[1];
let href = &caps[2]; let href = &caps[2];
let decoded = percent_decode(href); 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('#') || decoded.starts_with('#')
{ {
return format!("[{}]({})", display, href); return format!("[{}]({})", display, href);
@@ -549,9 +565,7 @@ fn fix_md_link_refs(
.to_string() .to_string()
} }
fn move_attachments( fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
vault: &Path,
) -> Result<HashMap<String, String>, String> {
let attachments_dir = vault.join(".helixnotes").join("attachments"); let attachments_dir = vault.join(".helixnotes").join("attachments");
let _ = std::fs::create_dir_all(&attachments_dir); let _ = std::fs::create_dir_all(&attachments_dir);
@@ -601,10 +615,7 @@ fn move_attachments(
Ok(moved) Ok(moved)
} }
fn rewrite_attachment_refs( fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
vault: &Path,
moved: &HashMap<String, String>,
) -> Result<(), String> {
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?; let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
let md_files: Vec<_> = walkdir::WalkDir::new(vault) 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()) .map(|e| e.path().to_path_buf())
.collect(); .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 { for dir in dirs {
let dir_str = dir.to_string_lossy(); let dir_str = dir.to_string_lossy();
@@ -719,7 +730,8 @@ fn is_dimension_spec(s: &str) -> bool {
if s.is_empty() { if s.is_empty() {
return false; 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 { 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"), extract_heading_title("# My Title\n\nBody"),
Some("My Title".to_string()) 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("Body without heading"), None);
assert_eq!(extract_heading_title("## Subheading"), None); assert_eq!(extract_heading_title("## Subheading"), None);
assert_eq!(extract_heading_title(""), None); assert_eq!(extract_heading_title(""), None);
@@ -947,8 +962,7 @@ mod tests {
#[test] #[test]
fn test_normalize_tags_strips_hash() { fn test_normalize_tags_strips_hash() {
let mapping: serde_yaml::Mapping = let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
let tags = normalize_tags(&mapping); let tags = normalize_tags(&mapping);
assert_eq!(tags, vec!["hashed"]); 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") 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> { pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
let hn_dir = helixnotes_dir(vault_path); let hn_dir = helixnotes_dir(vault_path);
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?; 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 paths
.par_iter() .par_iter()
.map(|path| { .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 let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(path) .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 let entries: Vec<NotebookEntry> = paths
.par_iter() .par_iter()
.map(|path| { .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 let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(path) .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> { 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 scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path); let root = Path::new(scan_path);
ensure_vault_content_dir(vault_path, root)?;
let vault_root = Path::new(vault_path); 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() { if !root.exists() {
return Err("Path does not exist".to_string()); 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(); .collect();
log::info!("scan_notes: mobile scan found {} notes", notes.len()); log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
return Ok(notes); return Ok(notes);
} }
@@ -301,7 +416,9 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
} else { } else {
WalkDir::new(root) WalkDir::new(root)
.into_iter() .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()) .filter_map(|e| e.ok())
.map(|e| e.path().to_path_buf()) .map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) .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()) .filter_map(|path| read_note_entry_fast(path, vault_root).ok())
.collect(); .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) Ok(notes)
} }
} }
@@ -423,8 +540,18 @@ fn read_note_entry_from_str(
}) })
} }
pub fn read_note(path: &str) -> Result<NoteContent, String> { pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
let p = Path::new(path); 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 raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p let filename = p
.file_name() .file_name()
@@ -454,14 +581,15 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
} }
Ok(NoteContent { Ok(NoteContent {
path: path.to_string(), path: reported_path.to_string(),
meta, meta,
content, content,
raw, 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(); let mut updated_meta = meta.clone();
updated_meta.modified = Utc::now(); 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 // 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() { let raw = if existing.is_empty() {
frontmatter::update_note_raw(&updated_meta, body) frontmatter::update_note_raw(&updated_meta, body)
} else { } else {
@@ -487,14 +615,11 @@ pub fn create_note(
notebook_relative: Option<&str>, notebook_relative: Option<&str>,
title: &str, title: &str,
) -> Result<NoteEntry, String> { ) -> Result<NoteEntry, String> {
let dir = match notebook_relative { let requested_dir = match notebook_relative {
Some(rel) => Path::new(vault_path).join(rel), Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path), None => PathBuf::from(vault_path),
}; };
let dir = ensure_vault_content_dir(vault_path, &requested_dir)?;
if !dir.exists() {
return Err("Notebook directory does not exist".to_string());
}
let filename = sanitize_filename(title); let filename = sanitize_filename(title);
let mut file_path = dir.join(format!("{}.md", filename)); 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> { pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
let src = Path::new(path); let validated = ensure_note_path(vault_path, Path::new(path))?;
if !src.is_file() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
let parent = src let parent = src
.parent() .parent()
@@ -672,7 +795,9 @@ pub fn create_daily_note(
"eu" => target_date.format("%d/%m/%Y").to_string(), "eu" => target_date.format("%d/%m/%Y").to_string(),
_ => { _ => {
let locale = get_system_locale(); 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>, parent_relative: Option<&str>,
name: &str, name: &str,
) -> Result<NotebookEntry, String> { ) -> Result<NotebookEntry, String> {
let parent = match parent_relative { let requested_parent = match parent_relative {
Some(rel) => Path::new(vault_path).join(rel), Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
None => PathBuf::from(vault_path), None => PathBuf::from(vault_path),
}; };
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
let dir_path = parent.join(name); let dir_path = parent.join(safe_child_name(name)?);
if dir_path.exists() { if dir_path.exists() {
return Err("Notebook already exists".to_string()); 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> { pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
let src = Path::new(note_path); let validated = ensure_note_path(vault_path, Path::new(note_path))?;
if !src.exists() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?; 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> { pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
let src = Path::new(notebook_path); let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
if !src.exists() { let src = validated.as_path();
return Err("Notebook does not exist".to_string());
}
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?; 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> { pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path); let validated = ensure_note_path(vault_path, Path::new(path))?;
if !src.exists() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
// Read old title before renaming // Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?; 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(); let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note // 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) Ok(new_path_str)
} }
@@ -898,11 +1023,17 @@ fn update_wikilinks_after_rename(
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
if !path.is_file() { continue; } if !path.is_file() {
continue;
}
let path_str = path.to_string_lossy(); 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 // 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) { let content = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
@@ -918,22 +1049,13 @@ fn update_wikilinks_after_rename(
// If another note shares the same title, these would be ambiguous. // If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique { if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]] // 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace( result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]] // 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace( result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]] // 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace( result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
} }
// Path-based rules are always safe (paths are unique). // 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> { pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
let src = Path::new(path); let validated = ensure_notebook_path(vault_path, Path::new(path))?;
if !src.exists() { let src = validated.as_path();
return Err("Notebook does not exist".to_string());
}
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() { if new_path.exists() {
return Err("A notebook with that name already exists".to_string()); 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()) Ok(new_path.to_string_lossy().to_string())
} }
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> { pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
let src = Path::new(note_path); let validated = ensure_note_path(vault_path, Path::new(note_path))?;
if !src.exists() { let src = validated.as_path();
return Err("Note does not exist".to_string());
}
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() { if !dest_dir.is_dir() {
return Err("Destination notebook does not exist".to_string()); 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()) Ok(dest.to_string_lossy().to_string())
} }
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> { pub fn move_notebook(
let src = Path::new(notebook_path); vault_path: &str,
if !src.exists() || !src.is_dir() { notebook_path: &str,
return Err("Notebook does not exist".to_string()); 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() { if !dest_parent_path.is_dir() {
return Err("Destination does not exist".to_string()); 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> { pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() { 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); 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) .min_depth(1)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .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(); .count();
let dirname = path.file_name().unwrap_or_default().to_string_lossy(); let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// Strip timestamp prefix to get original notebook name // 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) let modified = fs::metadata(&path)
.and_then(|m| m.modified()) .and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t)) .map(DateTime::<Utc>::from)
.unwrap_or_else(|_| Utc::now()); .unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry { notebooks.push(TrashNotebookEntry {
name, 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)); notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified)); notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
Ok(TrashContents { notes, notebooks }) Ok(TrashContents { notes, notebooks })
} }
@@ -1101,15 +1229,17 @@ pub fn restore_note(
trash_path: &str, trash_path: &str,
dest_notebook: Option<&str>, dest_notebook: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let src = Path::new(trash_path); let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
if !src.exists() { let src = validated.as_path();
if !src.is_file() {
return Err("Trashed note does not exist".to_string()); 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), Some(nb) => PathBuf::from(nb),
None => PathBuf::from(vault_path), 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) // 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(); 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> { pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
let src = Path::new(trash_path); let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
if !src.exists() || !src.is_dir() { let src = validated.as_path();
if !src.is_dir() {
return Err("Trashed notebook does not exist".to_string()); return Err("Trashed notebook does not exist".to_string());
} }
let dirname = src.file_name().unwrap_or_default().to_string_lossy(); let dirname = src.file_name().unwrap_or_default().to_string_lossy();
// Try to read original path from sidecar .meta file // 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) { let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original original
} else { } else {
@@ -1156,7 +1289,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
name.to_string() 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 // Recreate parent directories if needed
if let Some(parent) = dest.parent() { 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> { 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()); let parent = p.parent().map(|pp| pp.to_path_buf());
if p.is_dir() { if p.is_dir() {
fs::remove_dir_all(p).map_err(|e| e.to_string())?; fs::remove_dir_all(p).map_err(|e| e.to_string())?;
@@ -1431,8 +1565,9 @@ pub fn sanitize_filename(name: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir, compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
load_notebook_icons, scan_notebooks, set_notebook_icon, helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
scan_notebooks, set_notebook_icon,
}; };
use std::fs; use std::fs;
use uuid::Uuid; use uuid::Uuid;
@@ -1441,10 +1576,7 @@ mod tests {
fn compares_numeric_segments_anywhere_in_names() { fn compares_numeric_segments_anywhere_in_names() {
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"]; let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
names.sort_by(|left, right| compare_natural_names(left, right)); names.sort_by(|left, right| compare_natural_names(left, right));
assert_eq!( assert_eq!(names, ["Class 2a", "Class 2b", "Class 10a", "Class 10b"]);
names,
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
);
assert_eq!( assert_eq!(
compare_natural_names("Class 02", "Class 2b"), compare_natural_names("Class 02", "Class 2b"),
std::cmp::Ordering::Less std::cmp::Ordering::Less
@@ -1592,6 +1724,75 @@ mod tests {
fs::remove_file(outside).unwrap(); 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] #[test]
fn duplicates_note_content_and_assigns_unique_identity_and_name() { fn duplicates_note_content_and_assigns_unique_identity_and_name() {
let vault = let vault =
@@ -1623,4 +1824,4 @@ mod tests {
fs::remove_dir_all(vault).unwrap(); fs::remove_dir_all(vault).unwrap();
} }
} }
+7 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.3.4", "version": "1.3.5",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
@@ -24,10 +24,14 @@
} }
], ],
"security": { "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": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": {
"allow": [],
"deny": [],
"requireLiteralLeadingDot": true
}
} }
} }
}, },
+9 -1
View File
@@ -71,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
return invoke("set_font_size", { size }); 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> { export async function setFontFamily(family: string): Promise<void> {
return invoke("set_font_family", { family }); return invoke("set_font_family", { family });
} }
@@ -185,7 +189,7 @@ export async function getNoteSwitcherTitles(
return invoke("get_note_switcher_titles", { recentPaths }); 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"); return invoke("get_graph_data");
} }
@@ -364,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path }); 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> { export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url }); return invoke("open_url", { url });
} }
+4 -4
View File
@@ -81,9 +81,9 @@
return null; return null;
} }
let sidebar: Sidebar; let sidebar = $state<Sidebar>();
let noteList: NoteList; let noteList = $state<NoteList>();
let editor: Editor; let editor = $state<Editor>();
let unlistenFileChange: (() => void) | null = null; let unlistenFileChange: (() => void) | null = null;
async function applyStartupTarget(target: StartupTarget): Promise<boolean> { async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
if (target.mode === 'notebook') { if (target.mode === 'notebook') {
@@ -829,7 +829,7 @@
<!-- Mobile Header --> <!-- Mobile Header -->
<div class="mobile-header"> <div class="mobile-header">
{#if $mobileView !== 'sidebar'} {#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"> <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"/> <path d="M15 18l-6-6 6-6"/>
</svg> </svg>
+188 -260
View File
@@ -54,7 +54,8 @@
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists'; import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
import { clearFormatting } from '$lib/editor/clearFormatting'; import { clearFormatting } from '$lib/editor/clearFormatting';
import { serializeInlineMarkdown } from '$lib/editor/markdown'; import { serializeInlineMarkdown } from '$lib/editor/markdown';
import { relativePath, resolvePathFromFile } from '$lib/utils/paths'; import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
import GraphView from './GraphView.svelte'; import GraphView from './GraphView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
import ImageViewer from './ImageViewer.svelte'; import ImageViewer from './ImageViewer.svelte';
@@ -132,6 +133,8 @@
let highlightDropdown = $state(false); let highlightDropdown = $state(false);
let alignDropdown = $state(false); let alignDropdown = $state(false);
let insertDropdown = $state(false); let insertDropdown = $state(false);
let tablePickerOpen = $state(false);
let tablePickerHover = $state({ rows: 0, cols: 0 });
function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) { function scrollEditorBodyToBottom(source: HTMLElement | null | undefined = editorElement) {
const editorBody = source?.closest('.editor-body') as HTMLElement | null; const editorBody = source?.closest('.editor-body') as HTMLElement | null;
@@ -306,6 +309,22 @@
let editorState = $state(0); let editorState = $state(0);
let editorStateRaf = 0; // RAF handle for batching toolbar updates 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 // AI
let aiMenu = $state<{ x: number; y: number } | null>(null); let aiMenu = $state<{ x: number; y: number } | null>(null);
let aiLoading = $state(false); let aiLoading = $state(false);
@@ -470,8 +489,6 @@
}); });
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null); let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
let tableContextMenu = $state<{ x: number; y: number; hasStyling: 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 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 imageViewer = $state<{ src: string; alt: string } | null>(null);
let copyToast = $state<'copying' | 'done' | null>(null); let copyToast = $state<'copying' | 'done' | null>(null);
@@ -2531,10 +2548,7 @@
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) { function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
if (!editor || !wikiLinkMenu) return; if (!editor || !wikiLinkMenu) return;
const { from } = wikiLinkMenu; const { from } = wikiLinkMenu;
// Delete the [[ trigger and query text
const to = editor.state.selection.from; 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 // For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
const displayText = entry.title; const displayText = entry.title;
let titleAttr = originalRef || entry.title; let titleAttr = originalRef || entry.title;
@@ -2546,17 +2560,16 @@
titleAttr = relPath + anchor; titleAttr = relPath + anchor;
} }
} }
tick().then(() => { const transaction = replaceWithWikiLink(
if (!editor) return; editor.state,
editor.chain().focus() from,
.insertContent({ to,
type: 'text', displayText,
text: displayText, { title: titleAttr, path: entry.path || '', aliased: displayText !== titleAttr },
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }], );
})
.run();
});
closeWikiLinkMenu(); closeWikiLinkMenu();
editor.view.dispatch(transaction);
editor.view.focus();
} }
function executeWikiLinkCommand(index: number) { function executeWikiLinkCommand(index: number) {
@@ -2743,21 +2756,13 @@
closeWikiLinkMenu(); closeWikiLinkMenu();
tick().then(() => { tick().then(() => {
if (!editor) return; if (!editor) return;
// Use a single ProseMirror transaction to replace [[query] with the editor.view.dispatch(replaceWithWikiLink(
// wiki-link and clear stored marks atomically, preventing the inclusive editor.state,
// mark from bleeding into subsequent text. Do NOT call deleteRange first menuFrom,
// — that would shift positions and make menuFrom/curTo invalid here. curTo,
const { tr, schema } = editor.view.state; display,
const wikiLinkMark = schema.marks.wikiLink.create({ { title: noteRef, path: '', aliased: display !== noteRef },
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);
}); });
} }
} else { } else {
@@ -2974,13 +2979,11 @@
if (src.startsWith('http://') || src.startsWith('https://')) { if (src.startsWith('http://') || src.startsWith('https://')) {
return convertFileSrc(src, 'imgproxy'); return convertFileSrc(src, 'imgproxy');
} }
// Decode percent-encoding (%20 → space, etc.) for filesystem resolution // Decode URL encoding and undo the extra leading slash added to Windows drive paths.
let decoded = decodeURIComponent(src); let decoded = normalizeLocalAssetPath(decodeURIComponent(src));
// Fix multiple leading slashes (from broken saves) // Keep repairing legacy POSIX paths that were saved with duplicate leading slashes.
if (decoded.match(/^\/{2,}/)) { if (/^\/{2,}/.test(decoded)) decoded = decoded.replace(/^\/{2,}/, '/');
decoded = decoded.replace(/^\/{2,}/, '/'); if (decoded.startsWith('/') || /^[A-Za-z]:\//.test(decoded)) {
}
if (decoded.startsWith('/')) {
return convertFileSrc(normalizePath(decoded)); return convertFileSrc(normalizePath(decoded));
} }
// Paths containing .helixnotes/ are vault-root relative (our own attachments) // Paths containing .helixnotes/ are vault-root relative (our own attachments)
@@ -3683,6 +3686,27 @@
requestAnimationFrame(() => el.focus()); 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 ── // ── In-note search functions ──
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null; let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -3925,42 +3949,7 @@
} }
function stripAssetSrc(src: string): string { function stripAssetSrc(src: string): string {
// blob: URLs are not persistable - they were temporary browser references return assetSourceToMarkdown(src, $activeNotePath, $appConfig?.active_vault ?? null);
if (src.startsWith('blob:')) return '';
// Convert imgproxy:// URLs back to original external URLs for saving
if (src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
try {
const url = new URL(src);
return decodeURIComponent(url.pathname.substring(1));
} catch {
return src;
}
}
// Convert asset:// URLs back to relative paths for saving
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost') && !src.startsWith('https://asset.localhost')) return src;
let absPath = '';
try {
const url = new URL(src);
absPath = decodeURIComponent(url.pathname);
} catch {
return src;
}
// Clean up any leading double/triple slashes (URL parsing artifact)
absPath = absPath.replace(/^\/{2,}/, '/');
absPath = absPath.replace(/^\/([A-Za-z]:\/)/, '$1').replace(/\\/g, '/');
const notePath = $activeNotePath;
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
if (vaultRoot && absPath.startsWith(vaultRoot + '/')) {
const vaultRelative = absPath.substring(vaultRoot.length + 1);
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
if (notePath) {
const normalizedNotePath = notePath.replace(/\\/g, '/');
const noteDir = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
return relativePath(noteDir, absPath);
}
return vaultRelative;
}
return absPath;
} }
function htmlToMarkdown(html: string): string { function htmlToMarkdown(html: string): string {
@@ -4855,34 +4844,13 @@
} }
function getImageAbsPath(src: string): string { function getImageAbsPath(src: string): string {
// asset:// or http://asset.localhost → extract absolute path const assetPath = assetUrlToLocalPath(src);
if (src.startsWith('asset:') || src.startsWith('http://asset.localhost')) { if (assetPath !== null) return assetPath;
try { return resolveVaultFilePath(
const url = new URL(src); decodeURIComponent(src),
let absPath = decodeURIComponent(url.pathname); $activeNotePath,
absPath = absPath.replace(/^\/{2,}/, '/'); $appConfig?.active_vault ?? null,
return absPath; );
} catch { /* fall through */ }
}
// Relative path → resolve against note directory
let decoded = decodeURIComponent(src);
if (decoded.match(/^\/{2,}/)) decoded = decoded.replace(/^\/{2,}/, '/');
if (decoded.startsWith('/')) return decoded;
if (decoded.includes('.helixnotes/')) {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) {
const idx = decoded.indexOf('.helixnotes/');
return `${vaultRoot}/${decoded.substring(idx)}`;
}
}
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`);
}
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return src;
} }
async function copyImageToClipboard() { async function copyImageToClipboard() {
@@ -5535,17 +5503,8 @@
function resolveNoteHref(href: string): string | null { function resolveNoteHref(href: string): string | null {
const decoded = decodeURIComponent(href); const decoded = decodeURIComponent(href);
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null; if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null;
let absPath = decoded; const path = resolveVaultFilePath(decoded, $activeNotePath, $appConfig?.active_vault ?? null);
if (!decoded.startsWith('/')) { return path.endsWith('.md') ? path : null;
const notePath = $activeNotePath;
if (notePath) {
absPath = resolvePathFromFile(notePath, decoded);
} else {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
return absPath.endsWith('.md') ? absPath : null;
} }
function linkMenuOpen() { function linkMenuOpen() {
@@ -5609,20 +5568,11 @@
} }
function resolveHrefToAbsPath(href: string): string { function resolveHrefToAbsPath(href: string): string {
const decoded = decodeURIComponent(href); return resolveVaultFilePath(
if (decoded.startsWith('/')) return decoded; decodeURIComponent(href),
// .helixnotes/ paths are always relative to vault root, not the note's directory $activeNotePath,
const vaultRoot = $appConfig?.active_vault; $appConfig?.active_vault ?? null,
if (decoded.startsWith('.helixnotes/') && vaultRoot) { );
return normalizePath(`${vaultRoot}/${decoded}`);
}
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`);
}
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return decoded;
} }
function isFileLink(href: string): boolean { function isFileLink(href: string): boolean {
@@ -6298,7 +6248,7 @@
></textarea> ></textarea>
</div> </div>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- 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} {:else}
<!-- Desktop: conditional rendering with line numbers --> <!-- Desktop: conditional rendering with line numbers -->
{#if $sourceMode} {#if $sourceMode}
@@ -6402,7 +6352,7 @@
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- 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}
{/if} {/if}
</div> </div>
@@ -6418,7 +6368,7 @@
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </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"> <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="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
@@ -6458,7 +6408,7 @@
<div class="outline-panel" style="width: {$outlineWidth}px"> <div class="outline-panel" style="width: {$outlineWidth}px">
<div class="outline-header"> <div class="outline-header">
<h3>Outline</h3> <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"> <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="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
@@ -6485,7 +6435,7 @@
<div class="info-panel"> <div class="info-panel">
<div class="info-panel-header"> <div class="info-panel-header">
<span class="info-panel-title">Note Info</span> <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"> <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"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
@@ -6581,7 +6531,7 @@
{#if editorReady && !$sourceMode && !$viewerNote} {#if editorReady && !$sourceMode && !$viewerNote}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- 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} {#if isMobile}
<!-- ═══ MOBILE formatting bar: compact, relevant buttons only ═══ --> <!-- ═══ MOBILE formatting bar: compact, relevant buttons only ═══ -->
@@ -6591,8 +6541,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> <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> </button>
{#if insertDropdown} {#if insertDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown insert-dropdown">
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}> <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> <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 Image
@@ -6643,16 +6592,15 @@
<!-- Heading dropdown --> <!-- Heading dropdown -->
<div class="fmt-dropdown-wrap"> <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> <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> </button>
{#if headingDropdown} {#if headingDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown">
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}> <button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
<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={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</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={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</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={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -6660,36 +6608,36 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Bold / Italic / Underline / Strike --> <!-- 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> <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>
<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> <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>
<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> <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>
<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> <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> </button>
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Link --> <!-- 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> <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> </button>
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Lists --> <!-- 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> <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>
<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> <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>
<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> <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> </button>
@@ -6743,7 +6691,7 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Highlight --> <!-- 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> <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> </button>
@@ -6782,8 +6730,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> <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> </button>
{#if insertDropdown} {#if insertDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown insert-dropdown">
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}> <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> <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 Image
@@ -6812,17 +6759,16 @@
<!-- Heading dropdown --> <!-- Heading dropdown -->
<div class="fmt-dropdown-wrap"> <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> <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> </button>
{#if headingDropdown} {#if headingDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown">
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}> <button class:active={isEditorActive('heading', { level: 1 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
<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={isEditorActive('heading', { level: 2 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</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={isEditorActive('heading', { level: 3 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</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={isEditorActive('heading', { level: 4 })} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</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={isEditorActive('paragraph')} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -6830,16 +6776,16 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Text formatting --> <!-- 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> <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>
<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> <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>
<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> <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>
<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> <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> </button>
@@ -6847,14 +6793,13 @@
<div class="fmt-dropdown-wrap"> <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"> <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> <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> </button>
{#if colorDropdown} {#if colorDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown color-grid-dropdown">
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
{#each textColors as color} {#each textColors as color}
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}"> <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> <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} {/if}
</button> </button>
@@ -6866,20 +6811,20 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Link --> <!-- 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> <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> </button>
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Lists --> <!-- 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> <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>
<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> <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>
<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> <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> </button>
@@ -6896,25 +6841,25 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Code & Code Block --> <!-- 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> <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>
<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> <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> </button>
<!-- Blockquote --> <!-- 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> <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> </button>
<!-- Collapsible Section --> <!-- 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> <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> </button>
<!-- Callout --> <!-- 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> <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> </button>
@@ -6924,18 +6869,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> <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> </button>
{#if tablePickerOpen} {#if tablePickerOpen}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown table-picker-dropdown">
<div class="fmt-dropdown table-picker-dropdown" onclick={(e) => e.stopPropagation()}>
<div class="table-picker-grid"> <div class="table-picker-grid">
{#each Array(8) as _, r} {#each Array(8) as _, r}
{#each Array(10) as _, c} {#each Array(10) as _, c}
<!-- svelte-ignore a11y_no_static_element_interactions --> <button
<div type="button"
class="table-picker-cell" class="table-picker-cell"
class:active={r < tablePickerHover.rows && c < tablePickerHover.cols} 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 }} onmouseenter={() => tablePickerHover = { rows: r + 1, cols: c + 1 }}
onclick={() => insertTable(r + 1, c + 1)} onclick={() => insertTable(r + 1, c + 1)}
></div> ></button>
{/each} {/each}
{/each} {/each}
</div> </div>
@@ -6955,22 +6900,21 @@
<!-- Highlight --> <!-- Highlight -->
<div class="fmt-dropdown-wrap"> <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> <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> </button>
{#if highlightDropdown} {#if highlightDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown color-grid-dropdown">
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
{#each highlightColors as color} {#each highlightColors as color}
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}"> <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> <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} {/if}
</button> </button>
{/each} {/each}
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);"> <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> <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} {: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> <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>
@@ -6981,10 +6925,10 @@
</div> </div>
<!-- Subscript & Superscript --> <!-- 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> <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>
<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> <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> </button>
@@ -6993,32 +6937,31 @@
<!-- Text Alignment --> <!-- Text Alignment -->
<div class="fmt-dropdown-wrap"> <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"> <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> <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> <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> <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} {: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> <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} {/if}
</button> </button>
{#if alignDropdown} {#if alignDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="fmt-dropdown align-dropdown">
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}> <button class:active={isEditorActive({ textAlign: 'left' })} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
<button class:active={(editorState, editor.isActive({ 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> <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 Left
</button> </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> <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 Center
</button> </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> <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 Right
</button> </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> <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 Justify
</button> </button>
@@ -7098,9 +7041,8 @@
{#if linkContextMenu} {#if linkContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-context-overlay" onclick={closeLinkContextMenu}> <div class="link-context-overlay" onclick={(e) => closeFromOverlay(e, closeLinkContextMenu)} onkeydown={(e) => closeOnEscape(e, closeLinkContextMenu)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px">
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="link-context-url">{linkContextMenu.href}</div> <div class="link-context-url">{linkContextMenu.href}</div>
<button onclick={linkMenuOpen}> <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"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -7145,9 +7087,8 @@
{#if textContextMenu} {#if textContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="text-ctx-overlay" onclick={closeTextContextMenu}> <div class="text-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTextContextMenu)} onkeydown={(e) => closeOnEscape(e, closeTextContextMenu)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px">
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
<button onclick={ctxCut}> <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> <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 Cut
@@ -7179,12 +7120,12 @@
</button> </button>
{#if ctxHeadingSubmenu} {#if ctxHeadingSubmenu}
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}> <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={isEditorActive('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={isEditorActive('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={isEditorActive('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: 4 })} onclick={() => ctxSetHeading(4)}>Heading 4</button>
<div class="text-ctx-sep"></div> <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> </div>
{/if} {/if}
</div> </div>
@@ -7275,9 +7216,8 @@
{#if tableContextMenu} {#if tableContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-overlay" onclick={closeTableContextMenu}> <div class="table-ctx-overlay" onclick={(e) => closeFromOverlay(e, closeTableContextMenu)} onkeydown={(e) => closeOnEscape(e, 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;">
<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()}>
<button onclick={tblAddRowBefore}> <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> <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 Add Row Above
@@ -7351,9 +7291,8 @@
{#if imageToolbar} {#if imageToolbar}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="img-toolbar-overlay" onclick={() => (imageToolbar = null)}> <div class="img-toolbar-overlay" onclick={(e) => closeFromOverlay(e, () => (imageToolbar = null))} onkeydown={(e) => closeOnEscape(e, () => (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">
<div class="img-toolbar" class:mobile-viewer-toolbar={isAndroid} style="left: {imageToolbar.x}px; top: {imageToolbar.y}px" onclick={(e) => e.stopPropagation()}>
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button> <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 === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button> <button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
@@ -7363,7 +7302,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3"/></svg>
</button> </button>
{/if} {/if}
{#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')} {#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost') && !imageToolbar.src.startsWith('https://imgproxy.localhost')}
<span class="img-toolbar-sep"></span> <span class="img-toolbar-sep"></span>
<button onclick={copyImageToClipboard} title="Copy image"> <button onclick={copyImageToClipboard} title="Copy image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
@@ -7399,8 +7338,8 @@
{#if mathModal} {#if mathModal}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="math-modal-overlay" onclick={cancelMathModal}> <div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelMathModal)} onkeydown={(e) => closeOnEscape(e, cancelMathModal)}>
<div class="math-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Math editor"> <div class="math-modal" role="dialog" aria-modal="true" aria-label="Math editor" tabindex="-1">
<div class="math-modal-header"> <div class="math-modal-header">
<span>{mathModal.editPos !== null ? 'Edit' : 'Insert'} {mathModal.kind === 'block' ? 'Math Block' : 'Inline Math'}</span> <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"> <button type="button" class="math-modal-close" onclick={cancelMathModal} aria-label="Close">
@@ -7415,7 +7354,7 @@
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitMathModal(); } if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitMathModal(); }
if (e.key === 'Escape') { e.preventDefault(); cancelMathModal(); } if (e.key === 'Escape') { e.preventDefault(); cancelMathModal(); }
}} }}
autofocus use:autofocus
></textarea> ></textarea>
<div class="math-modal-preview"> <div class="math-modal-preview">
{#if mathModal.tex.trim()} {#if mathModal.tex.trim()}
@@ -7439,8 +7378,8 @@
{#if secretModal} {#if secretModal}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="math-modal-overlay" onclick={cancelSecretModal}> <div class="math-modal-overlay" onclick={(e) => closeFromOverlay(e, cancelSecretModal)} onkeydown={(e) => closeOnEscape(e, cancelSecretModal)}>
<div class="math-modal secret-modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Encrypted secret editor"> <div class="math-modal secret-modal" role="dialog" aria-modal="true" aria-label="Encrypted secret editor" tabindex="-1">
<div class="math-modal-header"> <div class="math-modal-header">
<span>Insert Secret</span> <span>Insert Secret</span>
<button type="button" class="math-modal-close" onclick={cancelSecretModal} aria-label="Close"> <button type="button" class="math-modal-close" onclick={cancelSecretModal} aria-label="Close">
@@ -7507,8 +7446,8 @@
{#if viewerImportPickerOpen} {#if viewerImportPickerOpen}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="viewer-import-overlay" onclick={() => (viewerImportPickerOpen = false)}> <div class="viewer-import-overlay" onclick={(e) => closeFromOverlay(e, () => (viewerImportPickerOpen = false))} onkeydown={(e) => closeOnEscape(e, () => (viewerImportPickerOpen = false))}>
<div class="viewer-import-picker" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="viewer-import-picker" role="dialog" aria-modal="true" aria-label="Import image to folder" tabindex="-1">
<div class="viewer-import-header"> <div class="viewer-import-header">
<span>Import to folder</span> <span>Import to folder</span>
<button type="button" class="viewer-import-close" onclick={() => (viewerImportPickerOpen = false)} aria-label="Close"> <button type="button" class="viewer-import-close" onclick={() => (viewerImportPickerOpen = false)} aria-label="Close">
@@ -7533,11 +7472,8 @@
{#if tagMenu && $activeNote} {#if tagMenu && $activeNote}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <div class="tag-menu-overlay" onclick={(e) => closeFromOverlay(e, () => (tagMenu = null))} onkeydown={(e) => closeOnEscape(e, () => (tagMenu = null))}>
<div class="tag-menu-overlay" onclick={() => (tagMenu = null)}> <div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
<!-- 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()}>
{#if $activeNote.meta.tags.length > 0} {#if $activeNote.meta.tags.length > 0}
<div class="tag-menu-list"> <div class="tag-menu-list">
{#each $activeNote.meta.tags as tag} {#each $activeNote.meta.tags as tag}
@@ -7562,9 +7498,8 @@
{#if codeLangDropdown} {#if codeLangDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}> <div class="code-lang-overlay" onclick={(e) => closeFromOverlay(e, closeCodeLangDropdown)} onkeydown={(e) => closeOnEscape(e, closeCodeLangDropdown)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px">
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
<input class="code-lang-search" type="text" placeholder="Search..." bind:this={codeLangInput} bind:value={codeLangSearch} onkeydown={(e) => { <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 === 'Enter' && codeLangFiltered.length > 0) { e.preventDefault(); e.stopPropagation(); selectCodeLang(codeLangFiltered[0]); }
if (e.key === 'Escape') closeCodeLangDropdown(); if (e.key === 'Escape') closeCodeLangDropdown();
@@ -7592,9 +7527,8 @@
{#if slashMenu} {#if slashMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu-overlay" onclick={closeSlashMenu}> <div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeSlashMenu)} onkeydown={(e) => closeOnEscape(e, closeSlashMenu)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px">
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px" onclick={(e) => e.stopPropagation()}>
{#if slashTablePicker} {#if slashTablePicker}
<div class="slash-table-picker"> <div class="slash-table-picker">
<div class="slash-table-picker-grid"> <div class="slash-table-picker-grid">
@@ -7656,9 +7590,8 @@
{#if taskMetaMenu} {#if taskMetaMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu-overlay" onclick={closeTaskMetaMenu}> <div class="slash-menu-overlay" onclick={(e) => closeFromOverlay(e, closeTaskMetaMenu)} onkeydown={(e) => closeOnEscape(e, closeTaskMetaMenu)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px">
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px" onclick={(e) => e.stopPropagation()}>
{#if taskMetaFiltered.length === 0} {#if taskMetaFiltered.length === 0}
<div class="slash-menu-empty">No match</div> <div class="slash-menu-empty">No match</div>
{:else} {:else}
@@ -7680,13 +7613,12 @@
{#if taskDuePicker} {#if taskDuePicker}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- 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 <input
type="date" type="date"
class="task-due-input" class="task-due-input"
bind:this={taskDueInputEl} bind:this={taskDueInputEl}
style="left: {taskDuePicker.x}px; top: {taskDuePicker.y}px" style="left: {taskDuePicker.x}px; top: {taskDuePicker.y}px"
onclick={(e) => e.stopPropagation()}
onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)} onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)}
onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }} onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }}
/> />
@@ -7695,9 +7627,8 @@
{#if wikiLinkMenu} {#if wikiLinkMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-overlay" onclick={closeWikiLinkMenu}> <div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, closeWikiLinkMenu)} onkeydown={(e) => closeOnEscape(e, closeWikiLinkMenu)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px">
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px" onclick={(e) => e.stopPropagation()}>
{#if wikiLinkFiltered.length === 0} {#if wikiLinkFiltered.length === 0}
<div class="wiki-link-empty"> <div class="wiki-link-empty">
{wikiLinkMenu.query ? 'No matching notes' : 'Type to search notes...'} {wikiLinkMenu.query ? 'No matching notes' : 'Type to search notes...'}
@@ -7726,9 +7657,8 @@
{#if wikiLinkNavDisambig} {#if wikiLinkNavDisambig}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-overlay" onclick={() => wikiLinkNavDisambig = null}> <div class="wiki-link-overlay" onclick={(e) => closeFromOverlay(e, () => wikiLinkNavDisambig = null)} onkeydown={(e) => closeOnEscape(e, () => wikiLinkNavDisambig = null)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px">
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px" onclick={(e) => e.stopPropagation()}>
<div class="wiki-link-disambig-header">Multiple notes found - choose one:</div> <div class="wiki-link-disambig-header">Multiple notes found - choose one:</div>
{#each wikiLinkNavDisambig.entries as entry, i} {#each wikiLinkNavDisambig.entries as entry, i}
<button <button
@@ -7750,9 +7680,8 @@
{#if aiMenu} {#if aiMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}> <div class="ai-menu-overlay" class:mobile={isMobile} onclick={(e) => closeFromOverlay(e, closeAiMenu)} onkeydown={(e) => closeOnEscape(e, 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`}>
<div class="ai-menu" class:mobile={isMobile} style={isMobile ? '' : `left: ${aiMenu.x}px; top: ${aiMenu.y}px`} onclick={(e) => e.stopPropagation()}>
{#if aiResult !== null || aiLoading} {#if aiResult !== null || aiLoading}
<!-- Result view --> <!-- Result view -->
<div class="ai-result-header"> <div class="ai-result-header">
@@ -7764,7 +7693,7 @@
AI Result AI Result
{/if} {/if}
</span> </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> <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> </button>
</div> </div>
@@ -7788,7 +7717,7 @@
{:else if aiShowCustom} {:else if aiShowCustom}
<!-- Custom prompt input --> <!-- Custom prompt input -->
<div class="ai-custom-header"> <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> <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> </button>
<span>Custom Prompt</span> <span>Custom Prompt</span>
@@ -7809,7 +7738,7 @@
{:else if aiTranslateMenu} {:else if aiTranslateMenu}
<!-- Translate submenu --> <!-- Translate submenu -->
<div class="ai-custom-header"> <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> <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> </button>
<span>Translate to</span> <span>Translate to</span>
@@ -7891,9 +7820,8 @@
{#if linkModal} {#if linkModal}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-modal-overlay" onclick={linkModalCancel}> <div class="link-modal-overlay" onclick={(e) => closeFromOverlay(e, linkModalCancel)} onkeydown={(e) => closeOnEscape(e, linkModalCancel)}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="link-modal" role="dialog" aria-modal="true" aria-label="Insert link" tabindex="-1">
<div class="link-modal" onclick={(e) => e.stopPropagation()}>
<div class="link-modal-header"> <div class="link-modal-header">
<svg width="28" height="28" viewBox="0 0 48 48" fill="none"> <svg width="28" height="28" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="var(--accent)" /> <rect width="48" height="48" rx="12" fill="var(--accent)" />
@@ -11316,11 +11244,11 @@
gap: 2px; gap: 2px;
} }
.toolbar-actions.mobile { .editor-container.mobile .toolbar-actions {
gap: 4px; gap: 4px;
} }
.toolbar-actions.mobile .icon-btn { .editor-container.mobile .toolbar-actions .icon-btn {
min-width: 32px; min-width: 32px;
min-height: 32px; min-height: 32px;
display: flex; display: flex;
@@ -11328,8 +11256,8 @@
justify-content: center; justify-content: center;
} }
.toolbar-actions.mobile .save-indicator, .editor-container.mobile .toolbar-actions .save-indicator,
.toolbar-actions.mobile .readonly-indicator { .editor-container.mobile .toolbar-actions .readonly-indicator {
font-size: 12px; font-size: 12px;
} }
+4 -4
View File
@@ -20,12 +20,12 @@
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; } interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
interface LabelRect { x: number; y: number; w: number; h: number; } interface LabelRect { x: number; y: number; w: number; h: number; }
let nodes: GraphNode[] = []; let nodes = $state.raw<GraphNode[]>([]);
let edges: GraphEdge[] = []; let edges = $state.raw<GraphEdge[]>([]);
let nodeIndexMap: Map<string, number> = new Map(); let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set(); let connectedSet: Set<number> = new Set();
let nodeDegree: number[] = []; let nodeDegree: number[] = [];
let searchMatchSet: Set<number> | null = null; let searchMatchSet = $state.raw<Set<number> | null>(null);
let folderColorMap = new Map<string, string>(); let folderColorMap = new Map<string, string>();
let pan = { x: 0, y: 0 }; let pan = { x: 0, y: 0 };
@@ -40,7 +40,7 @@
let hoveredNeighborSet: Set<number> = new Set(); let hoveredNeighborSet: Set<number> = new Set();
let hoveredEdgeSet: Set<number> = new Set(); let hoveredEdgeSet: Set<number> = new Set();
let glowPhase = 0; let glowPhase = 0;
let activeNodeIdx = -1; let activeNodeIdx = $state(-1);
let navigatedFromGraph = false; let navigatedFromGraph = false;
// Combined animation loop // Combined animation loop
+6 -3
View File
@@ -91,6 +91,10 @@
activeTab = isMobile ? 'about' : 'shortcuts'; activeTab = isMobile ? 'about' : 'shortcuts';
} }
function closeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function openLink(url: string) { function openLink(url: string) {
openUrl(url).catch(console.error); openUrl(url).catch(console.error);
} }
@@ -100,9 +104,8 @@
{#if $showInfo} {#if $showInfo}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}> <div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="info-panel">
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<div class="info-header"> <div class="info-header">
<h2>Info</h2> <h2>Info</h2>
<button class="close-btn" onclick={close} aria-label="Close info"> <button class="close-btn" onclick={close} aria-label="Close info">
+26 -39
View File
@@ -37,12 +37,12 @@
reorderQuickAccess, reorderQuickAccess,
moveNote, moveNote,
getAllTags, getAllTags,
createDailyNote createDailyNote,
revealFile
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time'; import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag'; import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte'; import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
@@ -966,11 +966,21 @@
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e)))); 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 (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
if (sortMenu) sortMenu = null; 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) { function openSortMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -1076,12 +1086,11 @@
{#if batchMovePicker} {#if batchMovePicker}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}> <div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header"> <div class="batch-move-header">
<span>Move {selectedPaths.size} notes to...</span> <span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
<button class="selection-close" onclick={() => batchMovePicker = false}> <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> <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> </button>
</div> </div>
@@ -1105,12 +1114,11 @@
{#if batchTagEdit && !contextMenu} {#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}> <div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header"> <div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span> <span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}> <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> <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> </button>
</div> </div>
@@ -1408,7 +1416,7 @@
</div> </div>
{#if contextMenu} {#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)} {#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
<!-- Batch context menu --> <!-- Batch context menu -->
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
@@ -1559,7 +1567,7 @@
Open in New Window Open in New Window
</button> </button>
{#if !isMobile} {#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"> <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"/> <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> </svg>
@@ -1611,8 +1619,7 @@
{/if} {/if}
{#if sortMenu} {#if sortMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="sort-menu-title">Sort by</div> <div class="sort-menu-title">Sort by</div>
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}> <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"> <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; border-radius: 8px;
} }
.note-list.mobile .sort-menu { .sort-menu.mobile {
min-width: 220px; min-width: 220px;
border-radius: 12px; border-radius: 12px;
padding: 6px; padding: 6px;
} }
.note-list.mobile .sort-menu button { .sort-menu.mobile button {
padding: 12px 16px; padding: 12px 16px;
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;
@@ -2365,26 +2372,6 @@
min-height: 44px; 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 note button (round, accent-colored) */
.mobile-create-btn { .mobile-create-btn {
background: var(--accent); background: var(--accent);
+8 -8
View File
@@ -30,14 +30,14 @@
{#if appWindow && !maximized} {#if appWindow && !maximized}
<div class="resize-layer" aria-hidden="true"> <div class="resize-layer" aria-hidden="true">
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div> <div class="rz rz-n" role="presentation" onmousedown={(e) => start(e, 'North')}></div>
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div> <div class="rz rz-s" role="presentation" onmousedown={(e) => start(e, 'South')}></div>
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div> <div class="rz rz-w" role="presentation" onmousedown={(e) => start(e, 'West')}></div>
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div> <div class="rz rz-e" role="presentation" onmousedown={(e) => start(e, 'East')}></div>
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div> <div class="rz rz-nw" role="presentation" onmousedown={(e) => start(e, 'NorthWest')}></div>
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div> <div class="rz rz-ne" role="presentation" onmousedown={(e) => start(e, 'NorthEast')}></div>
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div> <div class="rz rz-sw" role="presentation" onmousedown={(e) => start(e, 'SouthWest')}></div>
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div> <div class="rz rz-se" role="presentation" onmousedown={(e) => start(e, 'SouthEast')}></div>
</div> </div>
{/if} {/if}
+73 -44
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <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 { 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 { darkThemes, isMobile, isAndroid } from '$lib/platform';
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
@@ -583,6 +583,7 @@
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo'); let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf'); let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14); 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 activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6); let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1); let activeUiScale = $state($appConfig?.ui_scale ?? 1);
@@ -744,6 +745,10 @@
closeCustomThemeEditor(); closeCustomThemeEditor();
} }
function cancelCustomThemeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) cancelCustomThemeEditor();
}
async function selectCustomTheme(ct: CustomTheme) { async function selectCustomTheme(ct: CustomTheme) {
$theme = ct.id; $theme = ct.id;
setTheme(ct.id); setTheme(ct.id);
@@ -983,6 +988,13 @@
setFontSize(size).catch((e) => console.error('Failed to save font size:', e)); 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) { function applyFontSize(size: number) {
document.documentElement.style.setProperty('--editor-font-size', `${size}px`); document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
} }
@@ -1052,6 +1064,14 @@
$showSettings = false; $showSettings = false;
} }
function closeSettingsFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function dismissRestoreConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) restoreConfirm = null;
}
// Apply saved settings on mount // Apply saved settings on mount
$effect(() => { $effect(() => {
const savedAccent = $appConfig?.accent_color; const savedAccent = $appConfig?.accent_color;
@@ -1068,6 +1088,7 @@
} }
} }
} }
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
const savedSize = $appConfig?.font_size; const savedSize = $appConfig?.font_size;
if (savedSize) { if (savedSize) {
activeFontSize = savedSize; activeFontSize = savedSize;
@@ -1127,12 +1148,11 @@
{#if $showSettings} {#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}> <div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<div class="settings-header"> <div class="settings-header">
<h2>Settings</h2> <h2 id="settings-title">Settings</h2>
<button class="close-btn" onclick={close}> <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"> <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="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
@@ -1214,7 +1234,7 @@
<span class="setting-name">Compact mode</span> <span class="setting-name">Compact mode</span>
<span class="setting-desc">Show notes in a denser layout without preview</span> <span class="setting-desc">Show notes in a denser layout without preview</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1223,7 +1243,7 @@
<span class="setting-name">Show dates</span> <span class="setting-name">Show dates</span>
<span class="setting-desc">Show the date next to each note in the list</span> <span class="setting-desc">Show the date next to each note in the list</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1231,12 +1251,12 @@
<div class="settings-section"> <div class="settings-section">
<h3>Notebooks</h3> <h3>Notebooks</h3>
<label class="setting-toggle"> <div class="setting-toggle">
<span class="setting-label"> <span class="setting-label">
<span class="setting-name">Sort order</span> <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 class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
</span> </span>
</label> </div>
<div class="setting-options" style="margin-top: 8px;"> <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 === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button> <button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
@@ -1249,31 +1269,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> <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"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">All Notes</span></span> <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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Quick Access</span></span> <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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Tasks</span></span> <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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Daily Notes</span></span> <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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Trash</span></span> <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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1288,7 +1308,7 @@
<span class="setting-name">Show note switcher in title bar</span> <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 class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1312,7 +1332,7 @@
<span class="setting-name">Restore last session on launch</span> <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 class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1368,7 +1388,7 @@
<span class="setting-name">GPU Acceleration</span> <span class="setting-name">GPU Acceleration</span>
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span> <span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1381,7 +1401,7 @@
<span class="setting-name">Start at system startup</span> <span class="setting-name">Start at system startup</span>
<span class="setting-desc">Launch HelixNotes when your computer starts</span> <span class="setting-desc">Launch HelixNotes when your computer starts</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1390,7 +1410,7 @@
<span class="setting-name">Show in system tray</span> <span class="setting-name">Show in system tray</span>
<span class="setting-desc">Show an icon in the notification area (requires restart)</span> <span class="setting-desc">Show an icon in the notification area (requires restart)</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1400,7 +1420,7 @@
<span class="setting-name">Close to tray</span> <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 class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1489,7 +1509,7 @@
<span class="setting-name">Hide title in note body</span> <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 class="setting-desc">Hide the first heading when it matches the note title</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1498,7 +1518,7 @@
<span class="setting-name">Show link arrows</span> <span class="setting-name">Show link arrows</span>
<span class="setting-desc">Display the arrow icon after external links</span> <span class="setting-desc">Display the arrow icon after external links</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1508,7 +1528,7 @@
<span class="setting-name">Show line numbers</span> <span class="setting-name">Show line numbers</span>
<span class="setting-desc">Display line numbers in the markdown source editor</span> <span class="setting-desc">Display line numbers in the markdown source editor</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1518,7 +1538,7 @@
<span class="setting-name">Open notes in View Mode</span> <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 class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1527,7 +1547,7 @@
<span class="setting-name">Open new notes in source mode</span> <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 class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1541,7 +1561,7 @@
<span class="setting-name">Enable wiki links</span> <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 class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1556,7 +1576,7 @@
<span class="setting-name">Inline PDF preview</span> <span class="setting-name">Inline PDF preview</span>
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span> <span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -1793,24 +1813,23 @@
<!-- Custom Theme Editor Modal --> <!-- Custom Theme Editor Modal -->
{#if customThemeEditorOpen && customThemeEditing} {#if customThemeEditorOpen && customThemeEditing}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}> <div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
<div class="custom-theme-modal-header"> <div class="custom-theme-modal-header">
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3> <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}> <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> <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> </button>
</div> </div>
<div class="custom-theme-modal-body"> <div class="custom-theme-modal-body">
<div class="ct-field"> <div class="ct-field">
<label class="ct-label">Theme Name</label> <label class="ct-label" for="custom-theme-name">Theme Name</label>
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} /> <input id="custom-theme-name" class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
</div> </div>
<div class="ct-field"> <div class="ct-field">
<label class="ct-label">Mode</label> <span class="ct-label">Mode</span>
<div class="setting-options"> <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 = false; previewCustomTheme(); } }}>Light</button>
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button> <button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
@@ -1900,6 +1919,17 @@
</button> </button>
{/each} {/each}
</div> </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> </div>
{#if !isMobile} {#if !isMobile}
@@ -1977,7 +2007,7 @@
<label class="setting-toggle"> <label class="setting-toggle">
<span>Enable automatic backup</span> <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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -2005,7 +2035,7 @@
<span class="setting-name">Include attachments</span> <span class="setting-name">Include attachments</span>
<span class="setting-desc">Include images and files in backups (increases size significantly)</span> <span class="setting-desc">Include images and files in backups (increases size significantly)</span>
</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> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
@@ -2091,10 +2121,9 @@
{#if restoreConfirm} {#if restoreConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="restore-confirm-overlay" onclick={() => restoreConfirm = null}> <div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={(e) => { if (e.key === 'Escape') restoreConfirm = null; }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
<div class="restore-confirm" onclick={(e) => e.stopPropagation()}> <h4 id="restore-confirm-title">Restore Backup?</h4>
<h4>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> <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"> <div class="restore-confirm-actions">
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button> <button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
@@ -2443,14 +2472,14 @@
<span class="setting-name">Sync when a note changes</span> <span class="setting-name">Sync when a note changes</span>
<span class="setting-desc">Sync shortly after you edit a note.</span> <span class="setting-desc">Sync shortly after you edit a note.</span>
</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>
<label class="setting-toggle"> <label class="setting-toggle">
<span class="setting-label"> <span class="setting-label">
<span class="setting-name">Sync when the vault opens</span> <span class="setting-name">Sync when the vault opens</span>
<span class="setting-desc">Sync once when the app starts.</span> <span class="setting-desc">Sync once when the app starts.</span>
</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> </label>
</div> </div>
{/if} {/if}
+11 -7
View File
@@ -335,6 +335,10 @@
} }
} }
function dismissDeleteConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) deleteConfirm = null;
}
async function confirmDelete(nb: NotebookEntry) { async function confirmDelete(nb: NotebookEntry) {
deleteConfirm = null; deleteConfirm = null;
try { try {
@@ -1029,10 +1033,9 @@
{#if deleteConfirm} {#if deleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}> <div class="delete-confirm-overlay" onclick={dismissDeleteConfirm} onkeydown={(e) => { if (e.key === 'Escape') deleteConfirm = null; }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="delete-confirm" class:mobile={isMobile} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1">
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}> <h4 id="delete-confirm-title">Delete "{deleteConfirm.name}"?</h4>
<h4>Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p> <p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions"> <div class="delete-confirm-actions">
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button> <button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
@@ -1071,7 +1074,9 @@
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'} class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {4 + depth * 16}px" style="padding-left: {4 + depth * 16}px"
data-nb-path={nb.path} data-nb-path={nb.path}
onclick={() => selectNotebook(nb)} onclick={(e) => {
if (!(e.target as Element).closest('.nb-drag-handle')) selectNotebook(nb);
}}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'F2') { if (e.key === 'F2') {
e.preventDefault(); e.preventDefault();
@@ -1153,12 +1158,11 @@
{/if} {/if}
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span> <span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
{#if $notebookSortMode === 'manual'} {#if $notebookSortMode === 'manual'}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<span <span
class="nb-drag-handle" class="nb-drag-handle"
title="Drag to reorder" title="Drag to reorder"
onpointerdown={(e) => nbHandleDown(e, nb)} 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> <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> </span>
+4 -2
View File
@@ -214,19 +214,21 @@
{/if} {/if}
{#if isAndroid} {#if isAndroid}
<div class="location-selector"> <div class="location-selector" role="group" aria-labelledby="storage-location-label">
<label class="location-label">Location</label> <span id="storage-location-label" class="location-label">Location</span>
<div class="location-options"> <div class="location-options">
{#each storageLocations as loc} {#each storageLocations as loc}
<button <button
class="location-option" class="location-option"
class:active={selectedLocation === loc.label} class:active={selectedLocation === loc.label}
aria-pressed={selectedLocation === loc.label}
onclick={() => selectedLocation = loc.label} onclick={() => selectedLocation = loc.label}
>{loc.label}</button> >{loc.label}</button>
{/each} {/each}
<button <button
class="location-option" class="location-option"
class:active={selectedLocation === 'Custom'} class:active={selectedLocation === 'Custom'}
aria-pressed={selectedLocation === 'Custom'}
onclick={() => selectedLocation = 'Custom'} onclick={() => selectedLocation = 'Custom'}
>Custom</button> >Custom</button>
</div> </div>
+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; system_dark_theme: string;
accent_color: string | null; accent_color: string | null;
font_size: number | null; font_size: number | null;
scroll_to_change_font_size: boolean;
font_family: string | null; font_family: string | null;
line_height: number | null; line_height: number | null;
ui_scale: 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';
}
+62
View File
@@ -27,6 +27,68 @@ export function relativePath(fromDirectory: string, targetPath: string): string
return result.join('/') || '.'; return result.join('/') || '.';
} }
export function normalizeLocalAssetPath(path: string): string {
return path
.replace(/\\/g, '/')
.replace(/^\/([A-Za-z]:\/)/, '$1');
}
export function assetUrlToLocalPath(source: string): string | null {
if (!source.startsWith('asset:') && !source.startsWith('http://asset.localhost') && !source.startsWith('https://asset.localhost')) {
return null;
}
try {
const path = normalizeLocalAssetPath(decodeURIComponent(new URL(source).pathname));
// Tauri prepends a URL-path slash to POSIX and UNC paths.
return path.startsWith('//') ? path.substring(1) : path;
} catch {
return null;
}
}
export function resolveVaultFilePath(
targetPath: string,
notePath: string | null,
vaultRoot: string | null,
): string {
const target = normalizeLocalAssetPath(targetPath);
if (target.startsWith('/') || /^[A-Za-z]:\//.test(target)) return target;
const root = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '') ?? '';
if (target.startsWith('.helixnotes/') && root) {
return resolvePathFromFile(`${root}/.vault-root`, target);
}
if (notePath) return resolvePathFromFile(notePath, target);
if (root) return resolvePathFromFile(`${root}/.vault-root`, target);
return target;
}
export function assetSourceToMarkdown(
source: string,
notePath: string | null,
vaultRoot: string | null,
): string {
if (source.startsWith('blob:')) return '';
if (source.startsWith('imgproxy:') || source.startsWith('http://imgproxy.localhost') || source.startsWith('https://imgproxy.localhost')) {
try {
return decodeURIComponent(new URL(source).pathname.substring(1));
} catch {
return source;
}
}
const absolutePath = assetUrlToLocalPath(source);
if (absolutePath === null) return source;
const normalizedRoot = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '');
if (!normalizedRoot || !absolutePath.startsWith(normalizedRoot + '/')) return absolutePath;
const vaultRelative = absolutePath.substring(normalizedRoot.length + 1);
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
if (!notePath) return vaultRelative;
const normalizedNotePath = notePath.replace(/\\/g, '/');
const noteDirectory = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
return relativePath(noteDirectory, absolutePath);
}
export function resolvePathFromFile(filePath: string, targetPath: string): string { export function resolvePathFromFile(filePath: string, targetPath: string): string {
const normalizedFile = filePath.replace(/\\/g, '/'); const normalizedFile = filePath.replace(/\\/g, '/');
const normalizedTarget = targetPath.replace(/\\/g, '/'); const normalizedTarget = targetPath.replace(/\\/g, '/');
+6 -27
View File
@@ -7,6 +7,7 @@
import { darkThemes, isMobile, isAndroid } from '$lib/platform'; import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import type { CustomTheme } from '$lib/types'; import type { CustomTheme } from '$lib/types';
import ResizeHandles from '$lib/components/ResizeHandles.svelte'; import ResizeHandles from '$lib/components/ResizeHandles.svelte';
import { resolveVaultFilePath } from '$lib/utils/paths';
let { children } = $props(); let { children } = $props();
@@ -73,16 +74,6 @@
} }
} }
function normalizePath(p: string): string {
const parts = p.split('/');
const resolved: string[] = [];
for (const seg of parts) {
if (seg === '..') resolved.pop();
else if (seg !== '.') resolved.push(seg);
}
return resolved.join('/');
}
function openLocalFile(path: string) { function openLocalFile(path: string) {
if (isAndroid) { if (isAndroid) {
const bridge = (window as any).Android; const bridge = (window as any).Android;
@@ -98,24 +89,12 @@
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err)); openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) { } else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig); const config = get(appConfig);
const vaultRoot = config?.active_vault; const absPath = resolveVaultFilePath(
let absPath = decoded; decodeURIComponent(href),
if (!decoded.startsWith('/') && vaultRoot) { get(activeNotePath),
// .helixnotes/ paths are always relative to vault root, not the note's directory config?.active_vault ?? null,
if (decoded.startsWith('.helixnotes/')) { );
absPath = normalizePath(`${vaultRoot}/${decoded}`);
} else {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
}
// Internal .md note link - navigate within the app // Internal .md note link - navigate within the app
if (absPath.endsWith('.md')) { if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => { readNote(absPath).then((content) => {
+13 -2
View File
@@ -3,6 +3,7 @@
import { appConfig, vaultReady, theme } from '$lib/stores/app'; import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api'; import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes, isIOS, isMobile } from '$lib/platform'; import { darkThemes, isIOS, isMobile } from '$lib/platform';
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -16,6 +17,7 @@
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null; let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null; let removeEditorZoomShortcuts: (() => void) | null = null;
let removeFontSizeListener: (() => void) | null = null; let removeFontSizeListener: (() => void) | null = null;
let removeScrollFontSizeListener: (() => void) | null = null;
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null; let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
let startupWindowRevealed = false; let startupWindowRevealed = false;
@@ -121,10 +123,15 @@
}; };
const handleWheel = (event: WheelEvent) => { 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.preventDefault();
event.stopPropagation(); event.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1); if (action === 'increase') zoomEditor(1);
else if (action === 'decrease') zoomEditor(-1);
}; };
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
@@ -153,6 +160,9 @@
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => { removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload); 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'; $theme = config.theme || 'system';
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve // Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
@@ -287,6 +297,7 @@
onDestroy(() => { onDestroy(() => {
removeEditorZoomShortcuts?.(); removeEditorZoomShortcuts?.();
removeFontSizeListener?.(); removeFontSizeListener?.();
removeScrollFontSizeListener?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer); if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
if (startupRevealTimer) clearTimeout(startupRevealTimer); if (startupRevealTimer) clearTimeout(startupRevealTimer);
}); });
+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'
);
});