Compare commits

..
100 Commits
Author SHA1 Message Date
Yuri Karamian ac5ab2a27a Release v1.3.2 2026-06-22 23:06:19 +02:00
Yuri Karamian fef4ece2c0 Make editor + menus match the toolbar per platform (dedupe desktop, complete mobile) 2026-06-22 14:25:44 +02:00
Yuri Karamian 613451f4d8 Show search + info/settings icons in the collapsed sidebar rail 2026-06-22 13:45:01 +02:00
Yuri Karamian 04dee78b27 Add command-palette entries to reach hidden sidebar views (#153) 2026-06-22 13:28:24 +02:00
Yuri Karamian 69330bf23c Add command-palette entries to reach hidden sidebar views (#153) 2026-06-22 13:20:15 +02:00
Yuri Karamian 4843bcacd5 Reveal hidden notes list when selecting a sidebar view (#145) 2026-06-22 12:59:04 +02:00
Yuri Karamian 3d28665e1e Add settings to hide unused sidebar items (#153) 2026-06-22 12:52:07 +02:00
Yuri Karamian 209585a0b4 Preserve create date for notes without frontmatter dates (#139) 2026-06-22 12:50:50 +02:00
mf 84eab75ec8 Do not render Notes in Notebook sidebar (#152)
Keeps the notebook tree focused on notebooks and leaves note browsing to the note list.

![image](/attachments/a1e50fba-26b7-4dcd-9d44-9938783c6bc5)

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/152
2026-06-22 11:57:08 +02:00
Yuri Karamian ce17b38509 Add custom note sorting (#149) 2026-06-21 21:44:56 +02:00
Yuri Karamian 85c1e1e7d3 Add customizable keyboard shortcuts (#148) 2026-06-21 13:56:55 +02:00
mf ebf38e2281 Add notes list toggle shortcut (#145)
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/145
2026-06-21 12:50:31 +02:00
mf 32ec62419d Focus sub-notebook input from context menu (#150)
Focus the sub-notebook name field after choosing New Sub-notebook from a notebook context menu.

Validation: pnpm build
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/150
2026-06-21 12:49:41 +02:00
mf e46fda91a4 Add custom note sorting 2026-06-21 10:46:22 +03:30
mf 62d5388176 Add customizable keyboard shortcuts
Drive app shortcuts from a central keybindings store (defaults +
localStorage overrides). In the Info panel's Shortcuts tab: click a
shortcut to rebind, right-click to reset, Esc to cancel; combos
already in use are rejected.
2026-06-21 10:37:58 +03:30
Yuri Karamian db2bef892a Add Indent/Outdent buttons to mobile editor toolbar 2026-06-20 23:29:24 +02:00
Yuri Karamian 1c46935cc5 Add Note Width setting to cap editor content width (#137) 2026-06-20 15:01:01 +02:00
Yuri Karamian f0f19125d5 Pin Notebooks header while scrolling, add divider above it (#126) 2026-06-20 14:45:11 +02:00
Yuri Karamian 828f83d2e3 Preserve cursor position when toggling markdown source editor (#125) 2026-06-20 14:35:22 +02:00
Yuri Karamian 002fdb763e WebDAV: source manifest etag from PROPFIND, not PUT response (#147) 2026-06-20 14:14:43 +02:00
Yuri Karamian d3ff1fb931 Parse markdown links with angle brackets and titles (#146) 2026-06-20 13:01:18 +02:00
Yuri Karamian 7951472ae3 Fix reserved $ prefix in variable name 2026-06-19 14:36:10 +02:00
Yuri Karamian 5cffe8846b Fix table menu overflow and conditional reset button 2026-06-19 14:34:28 +02:00
Yuri Karamian 806e975e31 Fix table context menu going below screen 2026-06-19 14:30:32 +02:00
Yuri Karamian e4232269f6 Fix table styling not detected for HTML serialization 2026-06-19 14:29:00 +02:00
Yuri Karamian ab759a006d Fix table content loss in markdown serialization 2026-06-19 14:26:00 +02:00
Yuri Karamian ca7f25003c Tables serialize as markdown by default, HTML only when styled 2026-06-19 14:24:25 +02:00
Yuri Karamian 08ab0b90f2 Fix Enter key in language search truncating code block 2026-06-19 14:15:07 +02:00
Yuri Karamian e275f5c0bb Add PowerShell syntax highlighting 2026-06-19 14:05:04 +02:00
Yuri Karamian 683a01627e add highlight.js 2026-06-19 14:01:04 +02:00
Yuri Karamian 3c11a7f63e Add search filter to code block language selector 2026-06-19 14:00:39 +02:00
Yuri Karamian 90a96552d3 Always show code block controls at low opacity 2026-06-19 14:00:38 +02:00
Yuri Karamian a710603b0a Unify code block copy button and language selector styling 2026-06-19 14:00:38 +02:00
mf 80a6fdca69 Move single list items with Alt Shift arrows (#143)
Adds Alt+Shift+Up/Down for moving only the current list or task item while preserving the existing Alt+Up/Down block movement.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/143
2026-06-19 12:14:34 +02:00
mf 1dc78ca916 Add F2 rename shortcut (#142)
Adds F2 as a keyboard shortcut for focused note and notebook rows, reusing the existing inline rename flow.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/142
2026-06-19 12:14:20 +02:00
Yuri Karamian 3118af1e41 Merge branch 'fix/blob-images-source-mode' 2026-06-19 12:12:36 +02:00
Yuri Karamian a669d96aed Fix images not rendering after comment in source mode (#87) 2026-06-19 12:09:30 +02:00
Yuri Karamian a0b1fa0481 Fix images not rendering after comment in source mode (#87) 2026-06-19 12:03:49 +02:00
Yuri Karamian 2e8c048a3e Fix images not rendering after <- Client-side Create New Pull Zone button for allowed --> comment in source mode (#87) 2026-06-19 12:01:13 +02:00
Yuri Karamian d2ddc7f0de Fix editor not rendering on every other source mode switch 2026-06-19 11:54:13 +02:00
Yuri Karamian 3b88b20c61 Fix editor not rendering on every other source mode switch 2026-06-19 11:49:18 +02:00
Yuri Karamian f8371aaa57 Fix editor not rendering on every other source mode switch 2026-06-19 11:47:11 +02:00
Yuri Karamian c5a45131a5 Fix editor not rendering on every other source mode switch 2026-06-19 11:40:57 +02:00
Yuri Karamian 1681fade3b Fix editor not rendering on every other source mode switch 2026-06-19 11:38:40 +02:00
Yuri Karamian 4f324ad682 Fix pasted images disappearing when switching to source mode (#87) 2026-06-19 11:31:49 +02:00
Yuri Karamian c1a9871703 Fix pasted images disappearing when switching to source mode (#87) 2026-06-19 11:30:07 +02:00
Yuri Karamian 83390f5ed2 Fix images not rendering when switching back from source mode 2026-06-19 11:27:38 +02:00
Yuri Karamian 5bf6d8063e Fix images not rendering when switching back from source mode 2026-06-19 11:25:45 +02:00
Yuri Karamian a1913558b9 Fix pasted images disappearing when switching to source mode (#87) 2026-06-19 11:18:06 +02:00
Yuri Karamian 93e96829f4 Add setting to toggle link arrows (#128) 2026-06-19 11:06:49 +02:00
Yuri Karamian 11673bd8c4 Fix Android crash when swiping back with modal open (#90) 2026-06-19 10:47:39 +02:00
lokthare 4f4e2854a6 Update flake.nix / fix cargoHash (#136)
I wanted to try HelixNotes, I pulled the main branch but it failed to build with `nix build`.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/136
2026-06-19 09:54:58 +02:00
Yuri Karamian 856ac94a76 Centralize darkThemes list, fix dark themes reverting to light on restart 2026-06-18 13:50:33 +02:00
MF 28263de53d Improve notebook path creation (#135)
Backspace on an empty sub-notebook field returns to top-level creation, and top-level names like Parent/Child now create nested notebooks in one action. Validated with pnpm build.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/135
2026-06-18 13:40:41 +02:00
blake b6e855ce3e Accent colors now change automatically when a user selects a theme. Also added a One Dark theme. (#130)
WIP

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/130
2026-06-18 13:27:03 +02:00
MF 51148efe2e Focus notebook name after sidebar double-click (#134)
Double-clicking empty sidebar space now opens the new-notebook field and focuses it, matching the + button flow.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/134
2026-06-18 13:24:15 +02:00
Yuri Karamian 32e056dc24 Fix heading submenu clipped in context menu, flip on narrow screens (#113) 2026-06-18 13:20:35 +02:00
Yuri Karamian c14ad6387d Fix heading submenu clipped in context menu (#113) 2026-06-18 13:16:43 +02:00
Yuri Karamian 93122ade7b Show current vault indication in vault picker (#124) 2026-06-18 13:08:34 +02:00
blake 80bdabaf1b Material Dark, Material Light, Monokai, Rose Pine, Everforest, Horizon, Cyberpunk, Black themes all added. (#122)
WIP: New themes
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/122
2026-06-16 23:40:58 +02:00
Yuri Karamian cea98cffde update README 2026-06-16 23:20:22 +02:00
Yuri Karamian 922bcee1e2 Fix math/secret modal overflowing on mobile 2026-06-16 20:14:16 +02:00
Yuri Karamian d33bb8d96e Fix secret block overflowing on Android 2026-06-16 19:54:57 +02:00
Yuri Karamian 93ebb1987e Fix secret block overflowing on Android 2026-06-16 19:48:11 +02:00
Yuri Karamian cc0134dd22 Fix secret block overflowing on Android 2026-06-16 19:43:37 +02:00
Yuri Karamian 1164a6bd86 Fix wiki-link mark bleeding past ]] closure 2026-06-16 19:27:03 +02:00
blake 6fcf7bdb5e extra functionality for [[ ]]. Gives the option for the user to create the note if it doesn't exist
extra functionality for [[ ]]. Gives the option for the user to create the note if it doesn't exist.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/117
2026-06-16 15:09:08 +02:00
MF 8454f2eca8 Wrap selected text with typed pairs (#119)
Adds modular selection-pair handling for the rich editor and source mode so typing (, {, [, ', " or ` around a selection wraps it instead of replacing it.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/119
2026-06-16 14:59:14 +02:00
MF 7e480ad7e2 Keep zoom anchored in the editor (#118)
This keeps the visible note area stable when changing editor font size. It anchors the rich-text viewport before the font-size update and restores it after layout settles, so zooming shows the text getting larger or smaller without losing your place.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/118
2026-06-16 14:58:54 +02:00
MF bc7c985489 Add encrypted secret blocks (#111)
Adds /secret markdown blocks that store passphrase-protected secrets as helix-secret fences and unlock in the editor. Uses WebCrypto PBKDF2-HMAC-SHA256 with 600k iterations plus AES-256-GCM with salt, nonce, and AAD-bound metadata because it is dependency-free, portable to simple JS/Python decryptors, and supported by browser/webview WebCrypto. Tradeoff: PBKDF2 is not memory-hard like Argon2id/scrypt, so protection depends on strong passphrases and the high iteration count.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/111
2026-06-15 18:47:42 +02:00
MF a94c33edaf Scroll Past End (#116)
Adds editor scroll room after the final line so users can focus the active writing area without inserting blank lines. Ctrl+End moves to the document end and scrolls into that workspace; plain End keeps native line-end behavior. Presentation-only except cursor movement; does not mutate note content, mark dirty, or affect autosave.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/116
2026-06-15 18:47:05 +02:00
MF a1404062c5 Fix collapsible section title focus (#112)
Fixes keyboard navigation after creating a collapsible section: pressing Enter or Tab from the section title now moves the editor selection into the section body (`Start writing...`) instead of leaving the cursor in the title.

Validation: `COREPACK_ENABLE_AUTO_PIN=0 pnpm build`.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/112
2026-06-15 18:46:44 +02:00
MiladandFS 9bb55afccc Add editor zoom shortcuts (#108)
Adds Ctrl/Cmd +/-/0 and Ctrl/Cmd scroll shortcuts that change only markdown editor font size.

Co-authored-by: FS <failuresmith@proton.me>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/108
2026-06-15 18:46:17 +02:00
blake 173e25dadc Add a copy button for code snippets (#107)
WIP: Copy button for code snippets
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/107
2026-06-12 22:31:59 +02:00
Yuri Karamian 2fa9458eab Trash: empty/restore/delete now reflects immediately instead of needing an app restart (don't serve the volatile trash view from cache) 2026-06-12 17:57:48 +02:00
Yuri Karamian 0a3b7a13e3 Restore 8 themes dropped by the #106 merge (blueberry, forest-green, gruvbox, midnight-tide, cherry-blossom, synthwave, ember, moonlit); re-add their app.css blocks 2026-06-12 17:25:17 +02:00
blake 50d27d5636 main (#106)
WIP: Themes, accents, dropdown
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/106
2026-06-12 17:19:14 +02:00
Yuri Karamian d050ac907f Bundle Lora, Merriweather, Literata & Open Sans (self-hosted woff2 + OFL) so the editor serif/sans fonts render correctly on Windows (#100) 2026-06-12 14:45:03 +02:00
Yuri Karamian 80ca411a08 WebDAV sync: match remote paths encoding-agnostically so Infomaniak kDrive (and other providers that vary percent-encoding case) sync instead of wiping local notes (#102) 2026-06-12 14:42:09 +02:00
Yuri Karamian 35894cdfd1 Android: editor font-size setting now applies - disable WebView text-autosizing (layoutAlgorithm NORMAL) + respect the font-size var (#100) 2026-06-12 10:24:14 +02:00
Yuri Karamian 4df96362f4 Android: disable WebView text-size-adjust font-boosting so the editor font-size setting applies (#100) 2026-06-12 09:36:56 +02:00
Yuri Karamian fc7d34612d Bump version to 1.3.1 2026-06-12 09:22:12 +02:00
Yuri Karamian 1b7dc77792 Fix note breadcrumb showing 'Unfiled Notes' on Windows: normalize backslash paths before deriving the note's folder (#99) 2026-06-12 09:11:26 +02:00
blake 5f311f2404 Add 8 dark themes and 5 accent presets (#101) (#101)
WIP: New themes, new accents.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/101
2026-06-12 09:00:07 +02:00
Yuri Karamian f19c3c2caa Editor (#100): wrap long lines in Markdown source mode (no-wrap kept when line numbers are on) 2026-06-11 23:59:27 +02:00
Yuri Karamian 0f7b1debbb Editor: respect the font-size setting on Android (was hardcoded 16px); viewport already blocks auto-zoom so the hardcode was
unneeded (#100)
2026-06-11 23:45:32 +02:00
Yuri Karamian 4aa399e96e Fix desktop showing the mobile layout: decide layout from the actual build platform (cfg!(mobile)) instead of sniffing the webview user-agent, which some WebKitGTK builds report mobile-looking (#63) 2026-06-11 23:35:24 +02:00
Yuri Karamian 15c6e6388a Attachments: manual 'Find orphaned attachments' tool in the Info panel - scans all notes incl. trash, previews unreferenced files with sizes, moves to vault trash (recoverable); conservative raw+decoded matching, re-verified before moving, with a loading spinner (#52) 2026-06-11 23:16:59 +02:00
Yuri Karamian b501d2bd5c Notebooks: add a collapse-all / expand-all button to the sidebar tree header (#26) 2026-06-11 22:56:54 +02:00
Yuri Karamian fb4c5748a8 AI: OpenAI-compatible provider now uses /v1/chat/completions (not the legacy /v1/completions) so Nvidia NIM and similar work; tolerate a trailing /v1 in the base URL (#96) 2026-06-11 22:36:03 +02:00
Yuri Karamian d9cb615f1f Tasks: add a free-text filter box (matches task text or note title) (#95) 2026-06-11 22:25:51 +02:00
Yuri Karamian 0e09d1f9f1 Notebooks: fix drag-to-reorder and drag-out-to-root by deciding the drop action from the drop coordinates instead of cleared dragover state (#97) 2026-06-11 22:07:41 +02:00
Yuri Karamian db924f7352 Disable in-app updater for repo builds (HELIXNOTES_INSTALL_TYPE), open tel:/sms: links in the system handler (#98), label AI button \"OpenAI Compatible\" 2026-06-11 21:24:42 +02:00
Yuri Karamian 2fea44bde6 Vault picker: remove a vault from the Recent list (#93); fix Tasks view showing "All Notes" as the mobile header title 2026-06-11 20:48:20 +02:00
Yuri Karamian 784248f984 Tasks: per-vault persistence for the Flagged filter, Hide done, and sort mode (issue #94) 2026-06-11 15:50:18 +02:00
ArkHost ee16ef07c5 Merge pull request 'Update README.md - Add Solus install instructions' (#92) from clintre/HelixNotes:main into main
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/92
2026-06-11 09:12:03 +02:00
clintre 3648a2dc90 Update README.md - Add Solus install 2026-06-11 01:41:47 +02:00
Yuri Karamian 8f250d1ab9 Update README.md 2026-06-10 22:44:27 +02:00
Yuri Karamian 67d5ae87c3 Daily note title format setting (Localized/ISO/Long/US/EU); filenames stay ISO (#40) 2026-06-10 20:44:29 +02:00
Yuri Karamian 08abbe25ef update README 2026-06-10 18:03:50 +02:00
57 changed files with 5061 additions and 1029 deletions
+26 -15
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.9-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Latest Release](https://img.shields.io/badge/release-v1.3.2-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download (v1.2.9)
## Download (v1.3.2)
### Linux
@@ -26,6 +26,12 @@ yay -S helixnotes-appimage-bin
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### Solus (EOPKG)
```bash
sudo eopkg it helixnotes
```
#### NixOS
<details>
@@ -86,23 +92,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.9/HelixNotes_1.2.9_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_amd64.deb) (Ubuntu 22.04+)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64-setup.exe) (Windows 10/11)
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_android.apk)
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
---
@@ -111,19 +117,23 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features
- Markdown editor with toolbar, slash commands, source mode, code highlighting
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
- `[[Wiki-links]]` and graph view
- Full-text search (Tantivy)
- Outline panel, daily notes with calendar view, tags, drag-and-drop
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
- Manual notebook sorting (drag to reorder above, into, or below)
- External `.md` viewer mode with import-to-vault flow
- PDF preview, Obsidian import, "Show in File Manager"
- AI writing tools (Ollama / Anthropic / OpenAI)
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
- Version history with diffs, automatic backups
- Multi-window, file associations, focus mode, view mode
- Themes, accent colors, fonts
- Local files, no cloud
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
- Local plain-text files, no company cloud
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
@@ -158,9 +168,10 @@ pnpm tauri build
## Screenshots
![Editor](https://cdn.helixnotes.com/assets/screenshots/screenshot-1.png)
![Settings](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph View](https://cdn.helixnotes.com/assets/screenshots/screenshot-3.png)
![AI Actions](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.png)
![Tasks calendar](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph view](https://cdn.helixnotes.com/assets/screenshots/screenshot-7.png)
![Daily notes](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.png)
![Themes](https://cdn.helixnotes.com/assets/screenshots/screenshot-6.png)
## License
+1 -1
View File
@@ -22,7 +22,7 @@
inherit pname version;
src = ./.;
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.0",
"version": "1.3.2",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -59,6 +59,7 @@
"@tiptap/starter-kit": "^3.19.0",
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.1.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.0",
+3
View File
@@ -95,6 +95,9 @@ importers:
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
katex:
specifier: ^0.16.28
version: 0.16.28
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.0"
version = "1.3.2"
dependencies = [
"arboard",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.3.0"
version = "1.3.2"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
@@ -26,6 +26,13 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView)
this.webView = webView
webView.addJavascriptInterface(StorageBridge(this), "Android")
// Render text at the size CSS specifies, ignoring the device's system font-size
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
webView.settings.textZoom = 100
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
}
override fun onResume() {
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 633 B

+11 -165
View File
@@ -55,8 +55,8 @@ pub fn ai_request(
if url.is_empty() {
Err("No base URL configured for OpenAI Compatible provider".to_string())
} else {
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
stream_completions(
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
stream_openai(
&app,
&url,
key_opt,
@@ -94,6 +94,13 @@ pub fn ai_request(
});
}
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
fn normalize_openai_base(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
}
async fn stream_anthropic(
app: &AppHandle,
api_key: &str,
@@ -386,8 +393,8 @@ pub async fn test_connection(
if url.is_empty() {
return Err("No base URL configured for OpenAI Compatible provider".to_string());
}
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
test_completions(&url, key_opt, model).await
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
test_openai(&url, key_opt, model).await
}
_ => test_anthropic(api_key, model).await,
}
@@ -426,167 +433,6 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
}
}
async fn stream_completions(
app: &AppHandle,
url: &str,
api_key: Option<&str>,
model: &str,
system_prompt: &str,
user_message: &str,
_request_id: &str,
) -> Result<(), String> {
let client = Client::new();
let prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, user_message);
let body = json!({
"model": model,
"prompt": prompt,
"max_tokens": 4096,
"stream": true,
"temperature": 0.7
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
return Err(format!("API error {}: {}", status, body_text));
}
// Parse SSE stream
use futures::StreamExt;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete SSE events from buffer
while let Some(event_end) = buffer.find("\n\n") {
let event_str = buffer[..event_end].to_string();
buffer = buffer[event_end + 2..].to_string();
for line in event_str.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
// Completions streaming: choices[0].text
if let Some(text) = parsed["choices"][0]["text"].as_str() {
if !text.is_empty() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "text".to_string(),
text: Some(text.to_string()),
error: None,
},
);
}
}
// Check finish_reason
if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() {
if reason == "stop" || reason == "length" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
}
// Check for error in stream
if let Some(err) = parsed["error"]["message"].as_str() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "error".to_string(),
text: None,
error: Some(err.to_string()),
},
);
return Err(err.to_string());
}
}
}
}
}
}
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
Ok(())
}
async fn test_completions(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
let body = json!({
"model": model,
"prompt": "Hi",
"max_tokens": 10,
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
if response.status().is_success() {
Ok("Connection successful".to_string())
} else {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
Err(format!("API error {}: {}", status, body_text))
}
}
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
+161 -1
View File
@@ -51,6 +51,17 @@ pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> R
Ok(())
}
#[tauri::command]
pub fn remove_vault(state: State<'_, AppState>, path: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.vaults.retain(|v| v.path != path);
if config.active_vault.as_deref() == Some(path.as_str()) {
config.active_vault = None;
}
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
@@ -111,6 +122,14 @@ pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String
Ok(())
}
#[tauri::command]
pub fn set_content_width(state: State<'_, AppState>, width: Option<u32>) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.content_width = width;
save_app_config(&config)?;
Ok(())
}
// ── Notebooks ──
#[tauri::command]
@@ -300,7 +319,7 @@ pub fn create_note(
pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path, date.as_deref())?;
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
@@ -1068,6 +1087,7 @@ pub fn set_general_settings(
compact_notes: bool,
time_format: String,
week_start: String,
daily_title_format: String,
gpu_acceleration: bool,
autostart: bool,
pdf_preview: bool,
@@ -1075,19 +1095,31 @@ pub fn set_general_settings(
title_mode: String,
hide_title_in_body: bool,
show_line_numbers: bool,
show_link_arrows: bool,
default_view_mode: bool,
show_tray_icon: bool,
close_to_tray: bool,
enable_wiki_links: bool,
show_note_dates: bool,
restore_last_session: bool,
show_all_notes: bool,
show_quick_access: bool,
show_tasks: bool,
show_daily_notes: bool,
show_trash: bool,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.restore_last_session = restore_last_session;
config.show_all_notes = show_all_notes;
config.show_quick_access = show_quick_access;
config.show_tasks = show_tasks;
config.show_daily_notes = show_daily_notes;
config.show_trash = show_trash;
config.time_format = time_format;
config.week_start = week_start;
config.daily_title_format = daily_title_format;
config.gpu_acceleration = gpu_acceleration;
config.autostart = autostart;
config.pdf_preview = pdf_preview;
@@ -1095,6 +1127,7 @@ pub fn set_general_settings(
config.title_mode = title_mode;
config.hide_title_in_body = hide_title_in_body;
config.show_line_numbers = show_line_numbers;
config.show_link_arrows = show_link_arrows;
config.default_view_mode = default_view_mode;
config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray;
@@ -1645,6 +1678,115 @@ fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
Ok(result.to_string_lossy().to_string())
}
// ── Orphaned attachment cleanup ──
#[derive(serde::Serialize)]
pub struct OrphanAttachment {
pub name: String,
pub size: u64,
}
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files)
// and matches each filename against both the raw note text and a percent-decoded copy (so a
// URL-encoded path like `my%20file.png` still counts as a reference). When in doubt a file is
// KEPT: a leftover orphan is harmless, a wrong deletion is not.
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
let attachments_dir = operations::helixnotes_dir(vault).join("attachments");
if !attachments_dir.is_dir() {
return Ok(Vec::new());
}
let mut files: Vec<(String, u64)> = Vec::new();
for entry in std::fs::read_dir(&attachments_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let p = entry.path();
if p.is_file() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
files.push((name, size));
}
}
if files.is_empty() {
return Ok(Vec::new());
}
let mut haystack = String::new();
for entry in walkdir::WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
if let Ok(content) = std::fs::read_to_string(p) {
haystack.push_str(&content);
haystack.push('\n');
}
}
}
let decoded = percent_decode(&haystack);
let orphans = files
.into_iter()
.filter(|(name, _)| !haystack.contains(name.as_str()) && !decoded.contains(name.as_str()))
.collect();
Ok(orphans)
}
#[tauri::command]
pub fn find_orphaned_attachments(
state: State<'_, AppState>,
) -> Result<Vec<OrphanAttachment>, String> {
let vault = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.clone().ok_or("No active vault")?
};
let orphans = scan_orphaned_attachments(&vault)?;
Ok(orphans
.into_iter()
.map(|(name, size)| OrphanAttachment { name, size })
.collect())
}
#[tauri::command]
pub fn trash_orphaned_attachments(
state: State<'_, AppState>,
names: Vec<String>,
) -> Result<u32, String> {
let vault = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.clone().ok_or("No active vault")?
};
// Re-scan now and only move files that are STILL orphaned (guards against a reference added
// between scan and confirm), intersected with the caller's selection.
let current: std::collections::HashSet<String> = scan_orphaned_attachments(&vault)?
.into_iter()
.map(|(n, _)| n)
.collect();
let attachments_dir = operations::helixnotes_dir(&vault).join("attachments");
let trash_dir = operations::helixnotes_dir(&vault).join("trash");
std::fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S%3f").to_string();
let mut moved = 0u32;
for (i, name) in names.iter().enumerate() {
if !current.contains(name) {
continue;
}
// Path-traversal guard: only act on a bare filename inside the attachments dir.
if std::path::Path::new(name)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.as_deref()
!= Some(name.as_str())
{
continue;
}
let src = attachments_dir.join(name);
if !src.is_file() {
continue;
}
let dest = trash_dir.join(format!("{}_{}_attachment_{}", stamp, i, name));
if std::fs::rename(&src, &dest).is_ok() {
moved += 1;
}
}
Ok(moved)
}
fn percent_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.bytes();
@@ -1727,8 +1869,12 @@ fn xdg_open(arg: &str) -> Result<(), String> {
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: open the URL/path without flashing a console window.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
std::process::Command::new("cmd")
.args(["/C", "start", "", arg])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
@@ -2238,8 +2384,22 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> {
// ── Install Type Detection ──
#[tauri::command]
pub fn is_mobile_platform() -> bool {
// Compile-time platform: true only for the Android/iOS builds. Authoritative, unlike the
// webview user-agent, which some desktop WebKitGTK builds report mobile-looking (issue #63).
cfg!(mobile)
}
#[tauri::command]
pub fn get_install_type() -> String {
// Build-time override for distro packagers (e.g. Solus): build with
// HELIXNOTES_INSTALL_TYPE=solus to report that type and suppress the in-app updater.
if let Some(forced) = option_env!("HELIXNOTES_INSTALL_TYPE") {
if !forced.is_empty() {
return forced.to_string();
}
}
if cfg!(target_os = "macos") {
"macos".to_string()
} else if cfg!(target_os = "windows") {
+5
View File
@@ -98,6 +98,7 @@ pub fn run() {
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::open_vault,
commands::remove_vault,
commands::get_app_config,
commands::set_theme,
commands::set_accent_color,
@@ -105,6 +106,7 @@ pub fn run() {
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
commands::set_content_width,
commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook,
@@ -147,6 +149,8 @@ pub fn run() {
commands::remove_quick_access,
commands::reorder_quick_access,
commands::get_vault_stats,
commands::find_orphaned_attachments,
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::open_url,
@@ -168,6 +172,7 @@ pub fn run() {
commands::test_sync_connection,
commands::sync_now,
commands::get_install_type,
commands::is_mobile_platform,
commands::get_pending_open_file,
])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
+77 -22
View File
@@ -339,31 +339,72 @@ impl WebdavClient {
Ok(())
}
fn put_with_dirs(
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
/// to keep it consistent with future listings. (issue #147)
fn etag_for(&self, relpath: &str) -> String {
self.propfind(relpath, "0")
.ok()
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
.unwrap_or_default()
}
/// PUT a file (creating parent collections first), then read back its authoritative
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
fn upload(
&self,
relpath: &str,
bytes: &[u8],
created: &mut HashSet<String>,
) -> Result<Option<String>, String> {
) -> Result<String, String> {
self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes)
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
Ok(self.etag_for(relpath))
}
}
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
/// the base collection itself.
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
let path = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href).ok()?.path().to_string()
/// Map a WebDAV `href` to a vault-relative path (forward slash).
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
/// unmappable href makes that file look deleted on the remote, and the reconciler then
/// wipes the local copy. (issue #102)
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
// Pull out just the path component (some servers return absolute-URL hrefs).
let raw = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href)
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
.path()
.to_string()
} else {
href.to_string()
};
let path = path.trim_end_matches('/');
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
if rest.is_empty() {
return None;
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
// both sides first makes the prefix match encoding-agnostic. (issue #102)
let path = urlencoding::decode(raw.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
.into_owned();
let base = urlencoding::decode(base_path.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
.into_owned();
let rest = match path.strip_prefix(&base) {
Some(r) => r.trim_start_matches('/'),
None => {
return Err(format!(
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
Aborting before deleting anything locally - please report this URL."
))
}
Some(urlencoding::decode(rest).ok()?.into_owned())
};
if rest.is_empty() {
return Ok(None); // the base collection itself
}
Ok(Some(rest.to_string()))
}
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
@@ -427,7 +468,7 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
}
b"response" => {
if let Some(h) = href.take() {
if let Some(rel) = href_to_relpath(&h, base_path) {
if let Some(rel) = href_to_relpath(&h, base_path)? {
entries.push(RemoteEntry {
relpath: rel,
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
@@ -485,12 +526,12 @@ fn apply_changes(
);
} else if local_changed && !remote_changed {
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -530,12 +571,12 @@ fn apply_changes(
);
let ckey = conflict_relpath(key, &stamp);
write_local(vault_path, &ckey, &lbytes)?;
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
new_m.files.insert(
ckey,
ManifestEntry {
local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(),
remote_etag: cetag,
},
);
sum.conflicts += 1;
@@ -546,12 +587,12 @@ fn apply_changes(
None => {
// new local -> upload
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -560,12 +601,12 @@ fn apply_changes(
if me.local_hash != lf.hash {
// remote deleted but local edited -> resurrect (upload)
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -642,6 +683,20 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
let local = scan_local(&vault)?;
let manifest = load_manifest(&vault);
// Safety guard (issue #102): never treat an empty remote listing as "everything was
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
// map), not a real mass-deletion - so abort instead of wiping local notes.
if remote.is_empty() && !manifest.files.is_empty() {
return Err(
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
even though files were synced before, so no local files were deleted. This usually \
means the server reports paths in a format the app can't read yet - please report \
your WebDAV provider and folder URL."
.to_string(),
);
}
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
state.importing.store(true, Ordering::Relaxed);
+43
View File
@@ -72,6 +72,8 @@ pub struct AppConfig {
#[serde(default)]
pub ui_scale: Option<f64>,
#[serde(default)]
pub content_width: Option<u32>,
#[serde(default)]
pub compact_notes: bool,
#[serde(default = "default_true")]
pub show_note_dates: bool,
@@ -79,6 +81,8 @@ pub struct AppConfig {
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
#[serde(default = "default_daily_title_format")]
pub daily_title_format: String,
#[serde(default)]
pub gpu_acceleration: bool,
#[serde(default)]
@@ -93,6 +97,18 @@ pub struct AppConfig {
pub hide_title_in_body: bool,
#[serde(default)]
pub show_line_numbers: bool,
#[serde(default = "default_true")]
pub show_link_arrows: bool,
#[serde(default = "default_true")]
pub show_all_notes: bool,
#[serde(default = "default_true")]
pub show_quick_access: bool,
#[serde(default = "default_true")]
pub show_tasks: bool,
#[serde(default = "default_true")]
pub show_daily_notes: bool,
#[serde(default = "default_true")]
pub show_trash: bool,
#[serde(default)]
pub backup_enabled: bool,
#[serde(default = "default_backup_frequency")]
@@ -178,6 +194,10 @@ fn default_week_start() -> String {
"monday".to_string()
}
fn default_daily_title_format() -> String {
"localized".to_string()
}
fn default_max_versions() -> u32 {
20
}
@@ -197,10 +217,12 @@ impl Default for AppConfig {
font_family: None,
line_height: None,
ui_scale: None,
content_width: None,
compact_notes: false,
show_note_dates: true,
time_format: "relative".to_string(),
week_start: "monday".to_string(),
daily_title_format: "localized".to_string(),
gpu_acceleration: true,
autostart: false,
pdf_preview: false,
@@ -208,6 +230,12 @@ impl Default for AppConfig {
title_mode: "input".to_string(),
hide_title_in_body: false,
show_line_numbers: false,
show_link_arrows: true,
show_all_notes: true,
show_quick_access: true,
show_tasks: true,
show_daily_notes: true,
show_trash: true,
backup_enabled: false,
backup_frequency: "24h".to_string(),
backup_max_count: 10,
@@ -248,12 +276,16 @@ pub struct VaultState {
pub notelist_width: f64,
pub sidebar_collapsed: bool,
#[serde(default)]
pub notelist_collapsed: bool,
#[serde(default)]
pub collapsed_notebooks: Vec<String>,
#[serde(default)]
pub notebook_sort_mode: String,
#[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub note_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub sort_mode: String,
#[serde(default)]
pub last_view_mode: String,
@@ -263,6 +295,12 @@ pub struct VaultState {
pub last_tag: Option<String>,
#[serde(default)]
pub tasks_layout: String,
#[serde(default = "default_true")]
pub tasks_hide_completed: bool,
#[serde(default)]
pub tasks_only_flagged: bool,
#[serde(default)]
pub tasks_sort: String,
}
impl Default for VaultState {
@@ -272,14 +310,19 @@ impl Default for VaultState {
sidebar_width: 220.0,
notelist_width: 280.0,
sidebar_collapsed: false,
notelist_collapsed: false,
collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(),
note_order: std::collections::HashMap::new(),
sort_mode: String::new(),
last_view_mode: String::new(),
last_notebook: None,
last_tag: None,
tasks_layout: String::new(),
tasks_hide_completed: true,
tasks_only_flagged: false,
tasks_sort: String::new(),
}
}
}
+34 -3
View File
@@ -386,7 +386,26 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
.to_string_lossy()
.to_string();
let (meta, content) = frontmatter::parse_note(&raw, &filename);
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Fall back to filesystem times when the frontmatter has no created/modified, matching the
// note-list scan (read_note_entry_from_str). Without this, a missing `created:` defaults to
// Utc::now() here and then gets written to disk on the next save (e.g. a title change),
// resetting the create date of imported/frontmatter-less notes. (issue #139)
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
if let Ok(fs_meta) = fs::metadata(p) {
if !has_modified {
if let Ok(modified) = fs_meta.modified() {
meta.modified = modified.into();
}
}
if !has_created {
if let Ok(created) = fs_meta.created() {
meta.created = created.into();
}
}
}
Ok(NoteContent {
path: path.to_string(),
@@ -520,15 +539,27 @@ fn get_system_locale() -> Locale {
}
}
pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
pub fn create_daily_note(
vault_path: &str,
date: Option<&str>,
format: &str,
) -> Result<NoteEntry, String> {
let target_date = match date {
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.map_err(|e| format!("Invalid date: {}", e))?,
None => Local::now().date_naive(),
};
let date_str = target_date.format("%Y-%m-%d").to_string();
let title = match format {
"iso" => target_date.format("%Y-%m-%d").to_string(),
"long" => target_date.format("%B %-d, %Y").to_string(),
"us" => target_date.format("%m/%d/%Y").to_string(),
"eu" => target_date.format("%d/%m/%Y").to_string(),
_ => {
let locale = get_system_locale();
let title = target_date.format_localized("%B %d, %Y", locale).to_string();
target_date.format_localized("%B %d, %Y", locale).to_string()
}
};
let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.0",
"version": "1.3.2",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+714 -8
View File
@@ -33,7 +33,123 @@
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2") format("woff2");
src: url("/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2")
format("woff2");
}
/* Reading fonts - self-hosted (static/fonts/<name>/, OFL-1.1). Offered in the font picker;
bundled so they render the same on every platform instead of falling back to a system serif. */
@font-face {
font-family: "Lora";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/lora/Lora-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Lora";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/lora/Lora-Bold.woff2") format("woff2");
}
@font-face {
font-family: "Lora";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/lora/Lora-Italic.woff2") format("woff2");
}
@font-face {
font-family: "Lora";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/fonts/lora/Lora-BoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "Merriweather";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/merriweather/Merriweather-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Merriweather";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/merriweather/Merriweather-Bold.woff2") format("woff2");
}
@font-face {
font-family: "Merriweather";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/merriweather/Merriweather-Italic.woff2") format("woff2");
}
@font-face {
font-family: "Merriweather";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/fonts/merriweather/Merriweather-BoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "Literata";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/literata/Literata-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Literata";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/literata/Literata-Bold.woff2") format("woff2");
}
@font-face {
font-family: "Literata";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/literata/Literata-Italic.woff2") format("woff2");
}
@font-face {
font-family: "Literata";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/fonts/literata/Literata-BoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/open-sans/OpenSans-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/open-sans/OpenSans-Bold.woff2") format("woff2");
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/open-sans/OpenSans-Italic.woff2") format("woff2");
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/fonts/open-sans/OpenSans-BoldItalic.woff2") format("woff2");
}
:root {
@@ -64,8 +180,8 @@
--notelist-width: 280px;
--panel-resize-handle: 3px;
/* Monospace / code font (self-hosted JetBrains Mono, see @font-face above) */
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace,
monospace;
--font-mono:
"JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
}
:root.dark {
@@ -295,6 +411,581 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="blueberry"] {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--bg-hover: #1e2a4a;
--bg-active: #253558;
--bg-editor: #1a1a2e;
--text-primary: #e2e8f8;
--text-secondary: #a8b8d8;
--text-tertiary: #6a7fa8;
--text-accent: #7b9cff;
--border-color: #1e2a4a;
--border-light: #16213e;
--accent: #7b9cff;
--accent-hover: #6488f0;
--accent-light: color-mix(in srgb, #7b9cff 12%, transparent);
--danger: #ff6b8a;
--danger-hover: #e85575;
--success: #68d9a4;
--warning: #f0c96e;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="cherry-blossom"] {
--bg-primary: #1a0e12;
--bg-secondary: #221218;
--bg-tertiary: #2d1820;
--bg-hover: #3a1e2a;
--bg-active: #472535;
--bg-editor: #1a0e12;
--text-primary: #f0d6df;
--text-secondary: #c4919f;
--text-tertiary: #8a5565;
--text-accent: #f4a0b5;
--border-color: #3a1e2a;
--border-light: #2d1820;
--accent: #f4a0b5;
--accent-hover: #e88ea8;
--accent-light: color-mix(in srgb, #f4a0b5 12%, transparent);
--danger: #ff6b6b;
--danger-hover: #e85555;
--success: #a8d8b9;
--warning: #f7c59f;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="ember"] {
--bg-primary: #0f0a06;
--bg-secondary: #1a1008;
--bg-tertiary: #241608;
--bg-hover: #2e1e0a;
--bg-active: #3d2810;
--bg-editor: #0f0a06;
--text-primary: #f5e6d0;
--text-secondary: #c4a882;
--text-tertiary: #8a7055;
--text-accent: #ff8c42;
--border-color: #2e1e0a;
--border-light: #241608;
--accent: #ff8c42;
--accent-hover: #e87530;
--accent-light: color-mix(in srgb, #ff8c42 12%, transparent);
--danger: #ff4a36;
--danger-hover: #e03020;
--success: #7bc67e;
--warning: #ffc947;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
}
:root[data-theme="forest-green"] {
--bg-primary: #1a2318;
--bg-secondary: #1f2b1c;
--bg-tertiary: #253321;
--bg-hover: #2c3d27;
--bg-active: #35492e;
--bg-editor: #1a2318;
--text-primary: #d8e8d0;
--text-secondary: #a0b898;
--text-tertiary: #6a8560;
--text-accent: #6dbf67;
--border-color: #2c3d27;
--border-light: #253321;
--accent: #6dbf67;
--accent-hover: #58aa52;
--accent-light: color-mix(in srgb, #6dbf67 12%, transparent);
--danger: #e57373;
--danger-hover: #d05f5f;
--success: #81c784;
--warning: #ffcc66;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="gruvbox"] {
--bg-primary: #282828;
--bg-secondary: #1d2021;
--bg-tertiary: #3c3836;
--bg-hover: #504945;
--bg-active: #665c54;
--bg-editor: #282828;
--text-primary: #ebdbb2;
--text-secondary: #d5c4a1;
--text-tertiary: #bdae93;
--text-accent: #fabd2f;
--border-color: #504945;
--border-light: #3c3836;
--accent: #fabd2f;
--accent-hover: #e0a81a;
--accent-light: color-mix(in srgb, #fabd2f 12%, transparent);
--danger: #fb4934;
--danger-hover: #e83d28;
--success: #b8bb26;
--warning: #fe8019;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="midnight-tide"] {
--bg-primary: #020122;
--bg-secondary: #050a38;
--bg-tertiary: #091544;
--bg-hover: #0e2058;
--bg-active: #132d70;
--bg-editor: #020122;
--text-primary: #e2eeff;
--text-secondary: #99b0d4;
--text-tertiary: #4d6490;
--text-accent: #2dd4bf;
--border-color: #0e2058;
--border-light: #091544;
--accent: #2dd4bf;
--accent-hover: #1dbfab;
--accent-light: color-mix(in srgb, #2dd4bf 12%, transparent);
--danger: #ff6b8a;
--danger-hover: #e85575;
--success: #4ade80;
--warning: #fbbf24;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
}
:root[data-theme="moonlit"] {
--bg-primary: #0a0d12;
--bg-secondary: #0f1520;
--bg-tertiary: #141d2e;
--bg-hover: #1a263d;
--bg-active: #20304e;
--bg-editor: #0a0d12;
--text-primary: #dde8f5;
--text-secondary: #8fa8c8;
--text-tertiary: #4d6480;
--text-accent: #b0cceb;
--border-color: #1a263d;
--border-light: #141d2e;
--accent: #b0cceb;
--accent-hover: #90b0d8;
--accent-light: color-mix(in srgb, #b0cceb 12%, transparent);
--danger: #e07090;
--danger-hover: #c85a78;
--success: #70c8a0;
--warning: #e8c870;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
}
:root[data-theme="synthwave"] {
--bg-primary: #0d0221;
--bg-secondary: #130332;
--bg-tertiary: #1a0540;
--bg-hover: #220650;
--bg-active: #2c0860;
--bg-editor: #0d0221;
--text-primary: #f0e6ff;
--text-secondary: #b8a0d4;
--text-tertiary: #7a5fa0;
--text-accent: #ff6ac1;
--border-color: #220650;
--border-light: #1a0540;
--accent: #ff6ac1;
--accent-hover: #e554aa;
--accent-light: color-mix(in srgb, #ff6ac1 12%, transparent);
--danger: #ff4d6d;
--danger-hover: #e03a5a;
--success: #05ffa1;
--warning: #ffcc00;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
}
:root[data-theme="light-coffee"] {
--bg-primary: #fdf8f3;
--bg-secondary: #f5ede0;
--bg-tertiary: #ece0ce;
--bg-hover: #e2d0b8;
--bg-active: #d5c0a4;
--bg-editor: #fdf8f3;
--text-primary: #2c1f0e;
--text-secondary: #6b4d2e;
--text-tertiary: #9b7a58;
--text-accent: #8b5e3c;
--border-color: #e2d0b8;
--border-light: #ece0ce;
--accent: #8b5e3c;
--accent-hover: #7a4f30;
--accent-light: color-mix(in srgb, #8b5e3c 8%, transparent);
--danger: #c0392b;
--danger-hover: #a93226;
--success: #27ae60;
--warning: #e67e22;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:root[data-theme="dark-coffee"] {
--bg-primary: #1a1008;
--bg-secondary: #211408;
--bg-tertiary: #2a1c0e;
--bg-hover: #362410;
--bg-active: #422e16;
--bg-editor: #1a1008;
--text-primary: #f5e6d0;
--text-secondary: #c9a87a;
--text-tertiary: #8a6845;
--text-accent: #c8894d;
--border-color: #362410;
--border-light: #2a1c0e;
--accent: #c8894d;
--accent-hover: #b57840;
--accent-light: color-mix(in srgb, #c8894d 12%, transparent);
--danger: #e05a4a;
--danger-hover: #c94838;
--success: #7bc67e;
--warning: #ffc947;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
}
:root[data-theme="cotton-candy"] {
--bg-primary: #fff0f8;
--bg-secondary: #ffe8f4;
--bg-tertiary: #fddaee;
--bg-hover: #f9c8e4;
--bg-active: #f4b4d8;
--bg-editor: #fff0f8;
--text-primary: #3d1a30;
--text-secondary: #7a3d62;
--text-tertiary: #b07090;
--text-accent: #e85fa0;
--border-color: #f4c4dc;
--border-light: #fddaee;
--accent: #e85fa0;
--accent-hover: #d54a8e;
--accent-light: color-mix(in srgb, #e85fa0 8%, transparent);
--danger: #d43d3d;
--danger-hover: #c02e2e;
--success: #3bb56a;
--warning: #e0a030;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:root[data-theme="crimson"] {
--bg-primary: #1a0808;
--bg-secondary: #220a0a;
--bg-tertiary: #2d1010;
--bg-hover: #3a1515;
--bg-active: #481c1c;
--bg-editor: #1a0808;
--text-primary: #f5e0e0;
--text-secondary: #c88888;
--text-tertiary: #8a5555;
--text-accent: #e83535;
--border-color: #3a1515;
--border-light: #2d1010;
--accent: #e83535;
--accent-hover: #d02828;
--accent-light: color-mix(in srgb, #e83535 12%, transparent);
--danger: #ff6b6b;
--danger-hover: #e55555;
--success: #68c080;
--warning: #f0a040;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
}
:root[data-theme="cloud"] {
--bg-primary: #f0f8ff;
--bg-secondary: #e4f2fc;
--bg-tertiary: #d5e8f8;
--bg-hover: #c2d8f0;
--bg-active: #b0c8e8;
--bg-editor: #f0f8ff;
--text-primary: #1a3050;
--text-secondary: #4a6a90;
--text-tertiary: #7a9ab8;
--text-accent: #2879c8;
--border-color: #c8d8ec;
--border-light: #d5e8f8;
--accent: #2879c8;
--accent-hover: #1a68b8;
--accent-light: color-mix(in srgb, #2879c8 8%, transparent);
--danger: #e04545;
--danger-hover: #c83030;
--success: #2a8f58;
--warning: #d08020;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:root[data-theme="peach"] {
--bg-primary: #fff8f4;
--bg-secondary: #ffeee4;
--bg-tertiary: #ffe2d2;
--bg-hover: #ffd0b8;
--bg-active: #ffbea0;
--bg-editor: #fff8f4;
--text-primary: #3d1a0a;
--text-secondary: #7a4a30;
--text-tertiary: #b08060;
--text-accent: #e8704a;
--border-color: #ffd0b8;
--border-light: #ffe2d2;
--accent: #e8704a;
--accent-hover: #d85e38;
--accent-light: color-mix(in srgb, #e8704a 8%, transparent);
--danger: #d43030;
--danger-hover: #c02020;
--success: #30a060;
--warning: #d0980a;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:root[data-theme="material-dark"] {
--bg-primary: #212121;
--bg-secondary: #1b1b1b;
--bg-tertiary: #282828;
--bg-hover: #2d2d2d;
--bg-active: #393939;
--bg-editor: #212121;
--text-primary: #eeffff;
--text-secondary: #b0bec5;
--text-tertiary: #546e7a;
--text-accent: #80cbc4;
--border-color: #2d2d2d;
--border-light: #252525;
--accent: #80cbc4;
--accent-hover: #4db6ac;
--accent-light: color-mix(in srgb, #80cbc4 12%, transparent);
--danger: #ff5370;
--danger-hover: #e53050;
--success: #c3e88d;
--warning: #ffcb6b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
}
:root[data-theme="material-light"] {
--bg-primary: #fafafa;
--bg-secondary: #f5f5f5;
--bg-tertiary: #eeeeee;
--bg-hover: #e0e0e0;
--bg-active: #d6d6d6;
--bg-editor: #fafafa;
--text-primary: #212121;
--text-secondary: #546e7a;
--text-tertiary: #90a4ae;
--text-accent: #00897b;
--border-color: #e0e0e0;
--border-light: #eeeeee;
--accent: #00897b;
--accent-hover: #00796b;
--accent-light: color-mix(in srgb, #00897b 8%, transparent);
--danger: #e53935;
--danger-hover: #c62828;
--success: #388e3c;
--warning: #f57c00;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:root[data-theme="monokai"] {
--bg-primary: #272822;
--bg-secondary: #1e1f1c;
--bg-tertiary: #2d2e27;
--bg-hover: #3a3b32;
--bg-active: #49483e;
--bg-editor: #272822;
--text-primary: #f8f8f2;
--text-secondary: #a59f85;
--text-tertiary: #75715e;
--text-accent: #a6e22e;
--border-color: #3a3b32;
--border-light: #2d2e27;
--accent: #a6e22e;
--accent-hover: #8ec226;
--accent-light: color-mix(in srgb, #a6e22e 12%, transparent);
--danger: #f92672;
--danger-hover: #e01060;
--success: #a6e22e;
--warning: #e6db74;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="rose-pine"] {
--bg-primary: #191724;
--bg-secondary: #1f1d2e;
--bg-tertiary: #26233a;
--bg-hover: #312f44;
--bg-active: #403d52;
--bg-editor: #191724;
--text-primary: #e0def4;
--text-secondary: #908caa;
--text-tertiary: #6e6a86;
--text-accent: #c4a7e7;
--border-color: #312f44;
--border-light: #26233a;
--accent: #c4a7e7;
--accent-hover: #b094d8;
--accent-light: color-mix(in srgb, #c4a7e7 12%, transparent);
--danger: #eb6f92;
--danger-hover: #d05572;
--success: #9ccfd8;
--warning: #f6c177;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="everforest"] {
--bg-primary: #2d353b;
--bg-secondary: #272e33;
--bg-tertiary: #2e383c;
--bg-hover: #374145;
--bg-active: #414b50;
--bg-editor: #2d353b;
--text-primary: #d3c6aa;
--text-secondary: #9da9a0;
--text-tertiary: #7a8478;
--text-accent: #83c092;
--border-color: #374145;
--border-light: #2e383c;
--accent: #83c092;
--accent-hover: #6aac7a;
--accent-light: color-mix(in srgb, #83c092 12%, transparent);
--danger: #e67e80;
--danger-hover: #cc6264;
--success: #a7c080;
--warning: #dbbc7f;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="horizon"] {
--bg-primary: #1c1e26;
--bg-secondary: #16181f;
--bg-tertiary: #21232c;
--bg-hover: #2e303e;
--bg-active: #3a3c4e;
--bg-editor: #1c1e26;
--text-primary: #d5d8da;
--text-secondary: #9a9da5;
--text-tertiary: #626672;
--text-accent: #e9547f;
--border-color: #2e303e;
--border-light: #21232c;
--accent: #e9547f;
--accent-hover: #d83d6e;
--accent-light: color-mix(in srgb, #e9547f 12%, transparent);
--danger: #f43e5c;
--danger-hover: #e02848;
--success: #29d398;
--warning: #fab38e;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="cyberpunk"] {
--bg-primary: #0a0e17;
--bg-secondary: #060911;
--bg-tertiary: #0f1420;
--bg-hover: #141a28;
--bg-active: #1c2438;
--bg-editor: #0a0e17;
--text-primary: #e0e8ff;
--text-secondary: #8899cc;
--text-tertiary: #445588;
--text-accent: #00d9ff;
--border-color: #1c2438;
--border-light: #0f1420;
--accent: #ff0080;
--accent-hover: #e0006a;
--accent-light: color-mix(in srgb, #ff0080 12%, transparent);
--danger: #ff0044;
--danger-hover: #cc0033;
--success: #00ff88;
--warning: #ffcc00;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.7);
}
:root[data-theme="black"] {
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
--bg-tertiary: #111111;
--bg-hover: #1a1a1a;
--bg-active: #222222;
--bg-editor: #000000;
--text-primary: #f0f0f0;
--text-secondary: #999999;
--text-tertiary: #555555;
--text-accent: #ffffff;
--border-color: #1a1a1a;
--border-light: #111111;
--accent: #ffffff;
--accent-hover: #e0e0e0;
--accent-light: color-mix(in srgb, #ffffff 12%, transparent);
--danger: #ff3333;
--danger-hover: #cc2222;
--success: #33ff77;
--warning: #ffcc00;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.6);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.7);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.8);
}
:root[data-theme="one-dark"] {
--bg-primary: #282c34;
--bg-secondary: #21252b;
--bg-tertiary: #2c313a;
--bg-hover: #3e4451;
--bg-active: #4b5263;
--bg-editor: #282c34;
--text-primary: #abb2bf;
--text-secondary: #828997;
--text-tertiary: #5c6370;
--text-accent: #61afef;
--border-color: #3e4451;
--border-light: #2c313a;
--accent: #61afef;
--accent-hover: #4d9fdf;
--accent-light: color-mix(in srgb, #61afef 12%, transparent);
--danger: #e06c75;
--danger-hover: #cc5560;
--success: #98c379;
--warning: #e5c07b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
*,
*::before,
*::after {
@@ -309,6 +1000,10 @@ html,
body {
height: 100%;
overflow: hidden;
/* Stop the Android WebView from auto-scaling ("boosting") text, which ignores our
CSS font-size and makes the font-size setting do nothing on Android. (issue #100) */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
font-family: var(
--editor-font-family,
"Inter",
@@ -457,7 +1152,8 @@ body.resizing {
}
/* Force light theme colors for PDF */
:root, :root.dark {
:root,
:root.dark {
--bg-primary: #ffffff !important;
--bg-secondary: #f8f9fa !important;
--bg-tertiary: #f0f1f3 !important;
@@ -471,7 +1167,8 @@ body.resizing {
--border-light: #f0f1f3 !important;
}
html, body {
html,
body {
height: auto;
overflow: visible;
background: white !important;
@@ -511,16 +1208,25 @@ body.resizing {
}
/* Avoid breaking inside these elements */
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
page-break-after: avoid;
}
pre, blockquote, table, figure {
pre,
blockquote,
table,
figure {
page-break-inside: avoid;
}
/* Code blocks with light background */
.ProseMirror pre, .ProseMirror code {
.ProseMirror pre,
.ProseMirror code {
background: #f8f9fa !important;
color: #1a1a2e !important;
}
+39
View File
@@ -20,6 +20,10 @@ export async function openVault(path: string): Promise<void> {
return invoke("open_vault", { path });
}
export async function removeVault(path: string): Promise<void> {
return invoke("remove_vault", { path });
}
export async function getAppConfig(): Promise<AppConfig> {
return invoke("get_app_config");
}
@@ -48,6 +52,10 @@ export async function setUiScale(scale: number): Promise<void> {
return invoke("set_ui_scale", { scale });
}
export async function setContentWidth(width: number | null): Promise<void> {
return invoke("set_content_width", { width });
}
export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks");
}
@@ -216,6 +224,7 @@ export async function setGeneralSettings(
compactNotes: boolean,
timeFormat: string,
weekStart: string,
dailyTitleFormat: string,
gpuAcceleration: boolean,
autostart: boolean,
pdfPreview: boolean,
@@ -223,17 +232,24 @@ export async function setGeneralSettings(
titleMode: string,
hideTitleInBody: boolean,
showLineNumbers: boolean,
showLinkArrows: boolean,
defaultViewMode: boolean,
showTrayIcon: boolean,
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
restoreLastSession: boolean,
showAllNotes: boolean,
showQuickAccess: boolean,
showTasks: boolean,
showDailyNotes: boolean,
showTrash: boolean,
): Promise<void> {
return invoke("set_general_settings", {
compactNotes,
timeFormat,
weekStart,
dailyTitleFormat,
gpuAcceleration,
autostart,
pdfPreview,
@@ -241,12 +257,18 @@ export async function setGeneralSettings(
titleMode,
hideTitleInBody,
showLineNumbers,
showLinkArrows,
defaultViewMode,
showTrayIcon,
closeToTray,
enableWikiLinks,
showNoteDates,
restoreLastSession,
showAllNotes,
showQuickAccess,
showTasks,
showDailyNotes,
showTrash,
});
}
@@ -270,6 +292,19 @@ export async function getVaultStats(): Promise<VaultStats> {
return invoke("get_vault_stats");
}
export interface OrphanAttachment {
name: string;
size: number;
}
export async function findOrphanedAttachments(): Promise<OrphanAttachment[]> {
return invoke("find_orphaned_attachments");
}
export async function trashOrphanedAttachments(names: string[]): Promise<number> {
return invoke("trash_orphaned_attachments", { names });
}
export async function importObsidian(): Promise<void> {
return invoke("import_obsidian");
}
@@ -444,6 +479,10 @@ export async function getInstallType(): Promise<string> {
return invoke("get_install_type");
}
export async function isMobilePlatform(): Promise<boolean> {
return invoke("is_mobile_platform");
}
export async function getPendingOpenFile(): Promise<string | null> {
return invoke("get_pending_open_file");
}
+145 -53
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import Sidebar from './Sidebar.svelte';
import NoteList from './NoteList.svelte';
@@ -15,6 +15,7 @@
sidebarWidth,
notelistWidth,
sidebarCollapsed,
notelistCollapsed,
collapsedNotebooks,
showSearch,
showCommandPalette,
@@ -38,6 +39,9 @@
viewMode,
sortMode,
tasksLayout,
tasksHideCompleted,
tasksOnlyFlagged,
tasksSort,
activeTag,
updateAvailable as globalUpdateAvailable,
settingsTab,
@@ -45,14 +49,18 @@
viewerNote,
notebookSortMode,
notebookOrder,
syncState
noteOrder,
syncState,
platformIsMobile
} from '$lib/stores/app';
import { keybindings, matchAction } from '$lib/keybindings';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isMobile = $derived($platformIsMobile);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { darkThemes } from '$lib/platform';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store';
@@ -195,14 +203,19 @@
sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
note_order: $noteOrder,
sort_mode: $sortMode,
last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null,
last_tag: $activeTag,
tasks_layout: $tasksLayout
tasks_layout: $tasksLayout,
tasks_hide_completed: $tasksHideCompleted,
tasks_only_flagged: $tasksOnlyFlagged,
tasks_sort: $tasksSort
};
try {
await saveVaultState(state);
@@ -219,6 +232,19 @@
persistState();
}
function revealNoteList() {
$notelistCollapsed = false;
tick().then(() => noteList?.refresh());
}
function toggleNoteList() {
const nextCollapsed = !$notelistCollapsed;
$notelistCollapsed = nextCollapsed;
if (!nextCollapsed) {
tick().then(() => noteList?.refresh());
}
}
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false);
@@ -232,7 +258,14 @@
function handleViewChanged() {
taskNoteOpened = false;
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
if ($notelistCollapsed) {
revealNoteList();
} else {
noteList?.refresh();
}
if (isMobile) $mobileView = 'notelist';
}
@@ -323,7 +356,7 @@
let historyDepth = 0;
let navFromPopstate = false;
if (isMobile) {
if (get(platformIsMobile)) {
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
$effect(() => {
@@ -353,6 +386,16 @@
navFromPopstate = false;
return;
}
// If a modal is open, close it instead of navigating
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
$showSettings = false;
$showInfo = false;
$showSearch = false;
$showCommandPalette = false;
// Re-push the current state so the next back still works
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
return;
}
const state = e.state;
const targetDepth = state?.depth ?? 0;
historyDepth = targetDepth;
@@ -376,88 +419,76 @@
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') {
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
const action = matchAction(e, $keybindings);
if (action) {
e.preventDefault();
switch (action) {
case 'nav-back':
navigateHistory(-1);
return;
}
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
case 'nav-forward':
navigateHistory(1);
return;
}
if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault();
case 'new-note':
createAndFocusNote();
return;
}
if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault();
case 'toggle-theme': {
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault();
case 'search-vault':
$showSearch = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault();
case 'find-in-note':
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
}
if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault();
case 'quick-open':
$showCommandPalette = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault();
case 'save':
editor?.forceSave();
return;
}
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault();
case 'toggle-source':
$sourceMode = !$sourceMode;
}
if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault();
return;
case 'open-new-window':
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
}
if (mod && e.key === '\\') {
e.preventDefault();
return;
case 'toggle-sidebar':
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
case 'toggle-notelist':
toggleNoteList();
return;
case 'toggle-focus':
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
case 'toggle-readonly':
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
case 'fullscreen':
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
}
if (e.key === 'Escape') {
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
@@ -468,8 +499,7 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
@@ -495,15 +525,29 @@
persistState();
});
$effect(() => {
$sidebarCollapsed;
$notelistCollapsed;
persistState();
});
$effect(() => {
$notebookSortMode;
$notebookOrder;
persistState();
});
$effect(() => {
$noteOrder;
persistState();
});
$effect(() => {
$sortMode;
$tasksLayout;
$tasksHideCompleted;
$tasksOnlyFlagged;
$tasksSort;
persistState();
});
@@ -524,11 +568,16 @@
$sidebarWidth = state.sidebar_width;
$notelistWidth = state.notelist_width;
$sidebarCollapsed = state.sidebar_collapsed;
$notelistCollapsed = state.notelist_collapsed ?? false;
$collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
$noteOrder = state.note_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
if (state.tasks_sort === 'due' || state.tasks_sort === 'priority' || state.tasks_sort === 'note') $tasksSort = state.tasks_sort;
lastNotePath = state.last_open_note ?? null;
lastViewMode = state.last_view_mode ?? '';
lastNotebook = state.last_notebook ?? null;
@@ -712,7 +761,7 @@
{#if $mobileView === 'sidebar'}
HelixNotes
{:else}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if}
</span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -882,11 +931,24 @@
<ResizeHandle onResize={handleSidebarResize} />
{/if}
{#if !$notelistCollapsed}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
{:else}
<div class="notelist-restore-panel">
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="20" y2="6" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="18" x2="20" y2="18" />
<polyline points="5 8 3 12 5 16" />
</svg>
</button>
</div>
{/if}
{/if}
<div class="editor-panel">
@@ -905,7 +967,7 @@
{/if}
<SearchPanel />
<CommandPalette />
<CommandPalette onNavigate={handleViewChanged} />
<SettingsPanel />
<InfoPanel />
@@ -936,6 +998,36 @@
overflow: hidden;
}
.notelist-restore-panel {
flex-shrink: 0;
width: 36px;
height: 100%;
padding-top: 8px;
display: flex;
justify-content: center;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
}
.notelist-restore-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.notelist-restore-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.editor-panel {
flex: 1;
height: 100%;
+40 -3
View File
@@ -1,6 +1,19 @@
<script lang="ts">
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
import { setTheme, reindex } from '$lib/api';
import { darkThemes } from '$lib/platform';
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
$viewMode = mode;
$activeNotebook = null;
$activeTag = null;
$showCommandPalette = false;
onNavigate();
}
interface Command {
id: string;
@@ -25,6 +38,31 @@
$showSearch = true;
}
},
{
id: 'open-all-notes',
label: 'Open All Notes',
action: () => openView('all')
},
{
id: 'open-quick-access',
label: 'Open Quick Access',
action: () => openView('quickaccess')
},
{
id: 'open-tasks',
label: 'Open Tasks',
action: () => openView('tasks')
},
{
id: 'open-daily',
label: 'Open Daily Notes',
action: () => openView('daily')
},
{
id: 'open-trash',
label: 'Open Trash (restore deleted notes)',
action: () => openView('trash')
},
{
id: 'theme-light',
label: 'Switch to Light Theme',
@@ -142,8 +180,7 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
File diff suppressed because it is too large Load Diff
+257 -20
View File
@@ -1,16 +1,96 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api';
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
import {
ACTIONS,
keybindings,
setBinding,
resetBinding,
bindingFromEvent,
bindingToKeys,
bindingsEqual,
type ActionDef,
} from '$lib/keybindings';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Customizable shortcuts, grouped for display.
const actionGroups: { title: string; actions: ActionDef[] }[] = [
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
];
// id of the action currently listening for a new key combo, or null.
let capturingId = $state<string | null>(null);
// id of an action whose row should briefly flash a "already in use" warning.
let conflictId = $state<string | null>(null);
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
function startCapture(id: string) {
conflictId = null;
capturingId = id;
}
function stopCapture() {
capturingId = null;
}
function flashConflict(id: string) {
conflictId = id;
if (conflictTimer) clearTimeout(conflictTimer);
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
}
function captureKeydown(e: KeyboardEvent) {
if (!capturingId) return;
// Capture phase + stopImmediatePropagation keeps the combo from also
// triggering the app-level shortcut handler on window.
e.preventDefault();
e.stopImmediatePropagation();
if (e.key === 'Escape') { stopCapture(); return; }
const binding = bindingFromEvent(e);
if (!binding) return; // modifier-only press; keep listening
// Reject combos already bound to a different action.
const map = $keybindings;
const target = capturingId;
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
if (clash) {
flashConflict(target);
stopCapture();
return;
}
setBinding(target, binding);
stopCapture();
}
$effect(() => {
if (!capturingId) return;
window.addEventListener('keydown', captureKeydown, true);
return () => window.removeEventListener('keydown', captureKeydown, true);
});
function resetKey(e: MouseEvent, id: string) {
e.preventDefault();
resetBinding(id);
if (capturingId === id) stopCapture();
}
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...');
let orphans = $state<OrphanAttachment[] | null>(null);
let scanning = $state(false);
let trashing = $state(false);
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
@@ -35,8 +115,26 @@
getVaultStats().then((s) => { stats = s; }).catch(console.error);
} else {
stats = null;
orphans = null;
}
});
async function scanOrphans() {
scanning = true;
try { orphans = await findOrphanedAttachments(); }
catch (e) { console.error(e); }
finally { scanning = false; }
}
async function trashOrphans() {
if (!orphans || orphans.length === 0) return;
trashing = true;
try {
await trashOrphanedAttachments(orphans.map((o) => o.name));
orphans = null;
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
} catch (e) { console.error(e); }
finally { trashing = false; }
}
</script>
{#if $showInfo}
@@ -103,6 +201,25 @@
</div>
{/if}
{#if stats}
<div class="info-cleanup">
{#if orphans === null}
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
{:else if orphans.length === 0}
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
<div class="cleanup-list">
{#each orphans as o}
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
{/each}
</div>
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
{/if}
<div class="info-credits">
<p>Created by <strong>Yuri Karamian</strong></p>
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
@@ -116,27 +233,39 @@
</div>
{:else}
<div class="shortcuts-section">
<h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
{#if !isMobile}
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
{/if}
{#each actionGroups as group}
<h4 class="shortcuts-group-title">{group.title}</h4>
{#each group.actions as action}
<div class="shortcut-row">
<span class="shortcut-desc">{action.label}</span>
{#if isMobile}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{:else}
<button
class="shortcut-key-btn"
class:capturing={capturingId === action.id}
class:conflict={conflictId === action.id}
title="Click to rebind · right-click to reset"
onclick={() => startCapture(action.id)}
oncontextmenu={(e) => resetKey(e, action.id)}
>
{#if capturingId === action.id}
<span class="capture-prompt">Press keys…</span>
{:else if conflictId === action.id}
<span class="conflict-msg">Already in use</span>
{:else}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{/if}
</button>
{/if}
</div>
{/each}
{/each}
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
@@ -147,6 +276,7 @@
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
@@ -350,6 +480,71 @@
.info-link:hover {
background: var(--accent-light);
}
.info-cleanup {
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cleanup-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
padding: 7px 14px;
cursor: pointer;
}
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
.cleanup-msg {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.cleanup-list {
width: 100%;
max-height: 140px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.cleanup-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
font-size: 12px;
}
.cleanup-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cleanup-size {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cleanup-hint {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.shortcuts-section {
width: 100%;
@@ -394,6 +589,48 @@
color: var(--text-tertiary);
}
.shortcuts-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 4px;
}
.shortcut-key-btn {
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 4px;
margin: -2px -4px;
cursor: pointer;
display: flex;
align-items: center;
flex-shrink: 0;
font: inherit;
}
.shortcut-key-btn:hover {
background: var(--bg-hover);
}
.shortcut-key-btn.capturing {
border-color: var(--accent);
background: var(--accent-light);
}
.shortcut-key-btn.conflict {
border-color: var(--danger, #e11d48);
}
.capture-prompt {
font-size: 12px;
color: var(--accent);
}
.conflict-msg {
font-size: 12px;
color: var(--danger, #e11d48);
}
.shortcut-keys kbd,
.shortcut-desc kbd {
display: inline-block;
+126 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import {
notes,
sortedNotes,
@@ -11,6 +12,8 @@
editorDirty,
quickAccessPaths,
appConfig,
notelistCollapsed,
noteOrder,
notebooks,
tags,
mobileView
@@ -143,6 +146,7 @@
let sortMenu = $state<{ x: number; y: number } | null>(null);
let editingNote = $state<string | null>(null);
let editValue = $state('');
let renameInput: HTMLInputElement | null = $state(null);
let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null);
let tagEditTags = $state<string[]>([]);
@@ -203,6 +207,11 @@
let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
let noteDragFrom = $state<number | null>(null);
let noteDragOver = $state<number | null>(null);
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
const customOrderViews = new Set(['all', 'notebook', 'tag']);
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
// Virtual scroll
let listContainer = $state<HTMLDivElement>(null!);
@@ -310,7 +319,9 @@
}
const key = cacheKey();
const cached = noteCache.get(key);
// Never serve the trash view from cache: emptying / restoring / deleting changes it,
// and a stale cache left it showing cleared items until an app restart. Always re-read.
const cached = $viewMode === 'trash' ? undefined : noteCache.get(key);
if (cached) {
$notes = cached;
return;
@@ -410,6 +421,7 @@
}
try {
const entry = await createNote(nbRelative, 'Untitled');
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear();
await refresh();
await selectNote(entry);
@@ -427,6 +439,7 @@
try {
const newTitle = editValue.trim();
const newPath = await renameNote(note.path, newTitle);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear();
editingNote = null;
// Update local store immediately (avoid full vault re-scan)
@@ -448,6 +461,7 @@
contextMenu = null;
try {
await deleteNote(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear();
// Remove from local store immediately (avoid full vault re-scan)
$notes = $notes.filter(n => n.path !== note.path);
@@ -475,6 +489,7 @@
contextMenu = null;
try {
await permanentDelete(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) {
@@ -668,11 +683,60 @@
}
}
function persistManualNoteOrder(orderedNotes: NoteEntry[]) {
// Merge into (not replace) the order map so reordering notes in one view does
// not wipe the saved custom order of other notebooks / tags / the all-notes view.
const updates = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index]));
$noteOrder = { ...$noteOrder, ...updates };
}
function appendManualNoteOrder(path: string) {
const nextIndex = Math.max(-1, ...Object.values($noteOrder)) + 1;
$noteOrder = { ...$noteOrder, [path]: nextIndex };
}
function renameManualNoteOrderPath(oldPath: string, newPath: string) {
if (!Object.hasOwn($noteOrder, oldPath)) return;
const { [oldPath]: index, ...rest } = $noteOrder;
$noteOrder = { ...rest, [newPath]: index };
}
function removeManualNoteOrderPath(path: string) {
if (!Object.hasOwn($noteOrder, path)) return;
const { [path]: _, ...rest } = $noteOrder;
$noteOrder = rest;
}
function resetNoteDrag() {
noteDragFrom = null;
noteDragOver = null;
}
function handleCustomNoteDrop(targetIndex: number) {
if (noteDragFrom === null || !canCustomReorder) {
resetNoteDrag();
return;
}
let insertAt = noteDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
if (noteDragFrom === insertAt || noteDragFrom + 1 === insertAt) {
resetNoteDrag();
return;
}
const arr = [...$sortedNotes];
const [moved] = arr.splice(noteDragFrom, 1);
if (insertAt > noteDragFrom) insertAt--;
arr.splice(insertAt, 0, moved);
$notes = arr;
persistManualNoteOrder(arr);
resetNoteDrag();
}
async function handleMoveNote(note: NoteEntry, destPath: string) {
contextMenu = null;
movePickerNote = null;
try {
const newPath = await moveNote(note.path, destPath);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) {
@@ -699,10 +763,13 @@
contextMenu = { x, y, note };
}
function startRename(note: NoteEntry) {
async function startRename(note: NoteEntry) {
contextMenu = null;
editingNote = note.path;
editValue = note.meta.title;
await tick();
renameInput?.focus();
renameInput?.select();
}
function getNotebookPath(note: NoteEntry): string {
@@ -771,6 +838,7 @@
async function handleBatchDelete() {
const toDelete = new Set(selectedPaths);
noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null;
@@ -806,6 +874,7 @@
async function handleBatchPermanentDelete() {
const toDelete = new Set(selectedPaths);
noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null;
@@ -831,6 +900,9 @@
}
function setSortMode(mode: SortMode) {
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
persistManualNoteOrder($sortedNotes);
}
$sortMode = mode;
sortMenu = null;
}
@@ -854,6 +926,16 @@
<div class="list-header">
<span class="list-title">{viewTitle}</span>
<div class="list-actions">
{#if !isMobile}
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="16" y2="6" />
<line x1="4" y1="12" x2="16" y2="12" />
<line x1="4" y1="18" x2="16" y2="18" />
<polyline points="19 8 21 12 19 16" />
</svg>
</button>
{/if}
{#if $viewMode !== 'tasks'}
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
@@ -1053,6 +1135,7 @@
<input
type="text"
class="rename-input"
bind:this={renameInput}
bind:value={editValue}
onkeydown={(e) => {
if (e.key === 'Enter') handleRename(note);
@@ -1070,7 +1153,15 @@
class:compact={compact}
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(note);
}
}}
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
@@ -1097,6 +1188,12 @@
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (canCustomReorder) {
noteDragFrom = noteIndex;
e.dataTransfer!.setData('text/plain', note.path);
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else {
@@ -1111,18 +1208,28 @@
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
qaDragOver = noteIndex;
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
noteDragOver = noteIndex;
}
}}
ondragleave={() => {
if (qaDragOver === noteIndex) qaDragOver = null;
if (noteDragOver === noteIndex) noteDragOver = null;
}}
ondrop={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault();
handleQaDrop(noteIndex);
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
handleCustomNoteDrop(noteIndex);
}
}}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
>
{#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1401,6 +1508,18 @@
Title
{#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if}
</button>
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="4" cy="6" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="4" cy="18" r="1" />
</svg>
Custom
{#if $sortMode === 'custom'}<span class="sort-check">&#10003;</span>{/if}
</button>
</div>
{/if}
@@ -1923,11 +2042,13 @@
font-size: 14px;
}
.note-item.qa-drag-above {
.note-item.qa-drag-above,
.note-item.note-drag-above {
border-top: 2px solid var(--accent);
}
.note-item.qa-drag-below {
.note-item.qa-drag-below,
.note-item.note-drag-below {
border-bottom: 2px solid var(--accent);
}
+7 -7
View File
@@ -12,10 +12,13 @@
theme
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
import type { FileEvent } from '$lib/types';
let { notePath }: { notePath: string } = $props();
import { darkThemes } from '$lib/platform';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!);
@@ -35,8 +38,7 @@
$effect(() => {
const t = $theme || 'system';
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
@@ -72,13 +74,11 @@
}
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && e.code === 'KeyS') {
const action = matchAction(e, $keybindings);
if (action === 'save') {
e.preventDefault();
editor?.forceSave();
return;
}
if (mod && e.shiftKey && e.code === 'KeyM') {
} else if (action === 'toggle-source') {
e.preventDefault();
$sourceMode = !$sourceMode;
}
+397 -102
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
import { darkThemes } from '$lib/platform';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app';
@@ -9,6 +10,7 @@
import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
let activeTab = $state<Tab>('styling');
@@ -384,7 +386,6 @@
}
}
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
);
@@ -400,6 +401,29 @@
{ id: 'github-light', label: 'GitHub Light', bg: '#ffffff', sidebar: '#f6f8fa', accent: '#0969da' },
{ id: 'github-dark', label: 'GitHub Dark', bg: '#0d1117', sidebar: '#161b22', accent: '#58a6ff' },
{ id: 'dracula', label: 'Dracula', bg: '#282a36', sidebar: '#21222c', accent: '#bd93f9' },
{ id: 'midnight-tide', label: 'Midnight Tide', bg: '#020122', sidebar: '#050a38', accent: '#2dd4bf' },
{ id: 'blueberry', label: 'Blueberry', bg: '#1a1a2e', sidebar: '#16213e', accent: '#7b9cff' },
{ id: 'forest-green', label: 'Forest Green', bg: '#1a2318', sidebar: '#1f2b1c', accent: '#6dbf67' },
{ id: 'gruvbox', label: 'Gruvbox', bg: '#282828', sidebar: '#1d2021', accent: '#fabd2f' },
{ id: 'cherry-blossom', label: 'Cherry Blossom', bg: '#1a0e12', sidebar: '#221218', accent: '#f4a0b5' },
{ id: 'synthwave', label: 'Synthwave', bg: '#0d0221', sidebar: '#130332', accent: '#ff6ac1' },
{ id: 'ember', label: 'Ember', bg: '#0f0a06', sidebar: '#1a1008', accent: '#ff8c42' },
{ id: 'moonlit', label: 'Moonlit', bg: '#0a0d12', sidebar: '#0f1520', accent: '#b0cceb' },
{ id: 'light-coffee', label: 'Light Coffee', bg: '#fdf8f3', sidebar: '#f5ede0', accent: '#8b5e3c' },
{ id: 'dark-coffee', label: 'Dark Coffee', bg: '#1a1008', sidebar: '#211408', accent: '#c8894d' },
{ id: 'cotton-candy', label: 'Cotton Candy', bg: '#fff0f8', sidebar: '#ffe8f4', accent: '#e85fa0' },
{ id: 'crimson', label: 'Crimson', bg: '#1a0808', sidebar: '#220a0a', accent: '#e83535' },
{ id: 'cloud', label: 'Cloud', bg: '#f0f8ff', sidebar: '#e4f2fc', accent: '#2879c8' },
{ id: 'peach', label: 'Peach', bg: '#fff8f4', sidebar: '#ffeee4', accent: '#e8704a' },
{ id: 'material-dark', label: 'Material Dark', bg: '#212121', sidebar: '#1b1b1b', accent: '#80cbc4' },
{ id: 'material-light', label: 'Material Light', bg: '#fafafa', sidebar: '#f5f5f5', accent: '#00897b' },
{ id: 'monokai', label: 'Monokai', bg: '#272822', sidebar: '#1e1f1c', accent: '#a6e22e' },
{ id: 'rose-pine', label: 'Rosé Pine', bg: '#191724', sidebar: '#1f1d2e', accent: '#c4a7e7' },
{ id: 'everforest', label: 'Everforest', bg: '#2d353b', sidebar: '#272e33', accent: '#83c092' },
{ id: 'horizon', label: 'Horizon', bg: '#1c1e26', sidebar: '#16181f', accent: '#e9547f' },
{ id: 'cyberpunk', label: 'Cyberpunk', bg: '#0a0e17', sidebar: '#060911', accent: '#ff0080' },
{ id: 'black', label: 'Black', bg: '#000000', sidebar: '#0a0a0a', accent: '#ffffff' },
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
];
const accentPresets = [
@@ -411,6 +435,11 @@
{ name: 'Cyan', light: '#0891b2', dark: '#5ba8b5' },
{ name: 'Orange', light: '#ea580c', dark: '#c98560' },
{ name: 'Teal', light: '#0d9488', dark: '#5fada5' },
{ name: 'Sakura', light: '#d4547a', dark: '#f4a0b5' },
{ name: 'Gold', light: '#b8860b', dark: '#fabd2f' },
{ name: 'Lavender', light: '#9d5cdb', dark: '#c084fc' },
{ name: 'Sky', light: '#0284c7', dark: '#38bdf8' },
{ name: 'Coral', light: '#e85d4a', dark: '#ff8a7a' },
];
const fontSizePresets = [
@@ -449,11 +478,28 @@
{ label: '200%', value: 2 },
];
const contentWidthPresets: { label: string; value: number | null }[] = [
{ label: 'Narrow', value: 600 },
{ label: 'Medium', value: 760 },
{ label: 'Wide', value: 960 },
{ label: 'Full', value: null },
];
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14);
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null);
let themeDropdownOpen = $state(false);
let accentDropdownOpen = $state(false);
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
let isCustomAccent = $derived(activeAccent.startsWith('#'));
let activeAccentColor = $derived(
isCustomAccent ? activeAccent : (isThemeDark ? activeAccentPreset.dark : activeAccentPreset.light)
);
// General settings
let compactNotes = $state($appConfig?.compact_notes ?? false);
@@ -461,6 +507,7 @@
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
let timeFormat = $state($appConfig?.time_format ?? 'relative');
let weekStart = $state($appConfig?.week_start ?? 'monday');
let dailyTitleFormat = $state($appConfig?.daily_title_format ?? 'localized');
let gpuAcceleration = $state($appConfig?.gpu_acceleration ?? true);
let autostart = $state($appConfig?.autostart ?? false);
@@ -470,11 +517,19 @@
let titleMode = $state($appConfig?.title_mode ?? 'input');
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
// Sidebar item visibility
let showAllNotes = $state($appConfig?.show_all_notes ?? true);
let showQuickAccess = $state($appConfig?.show_quick_access ?? true);
let showTasks = $state($appConfig?.show_tasks ?? true);
let showDailyNotes = $state($appConfig?.show_daily_notes ?? true);
let showTrash = $state($appConfig?.show_trash ?? true);
const pdfHeightPresets = [
{ label: 'Small', value: 300 },
{ label: 'Medium', value: 450 },
@@ -519,6 +574,7 @@
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat;
$appConfig.week_start = weekStart;
$appConfig.daily_title_format = dailyTitleFormat;
$appConfig.gpu_acceleration = gpuAcceleration;
$appConfig.autostart = autostart;
$appConfig.pdf_preview = pdfPreview;
@@ -526,15 +582,32 @@
$appConfig.title_mode = titleMode;
$appConfig.hide_title_in_body = hideTitleInBody;
$appConfig.show_line_numbers = showLineNumbers;
$appConfig.show_link_arrows = showLinkArrows;
$appConfig.default_view_mode = defaultViewMode;
$appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks;
$appConfig.show_all_notes = showAllNotes;
$appConfig.show_quick_access = showQuickAccess;
$appConfig.show_tasks = showTasks;
$appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession)
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
.catch((e) => console.error('Failed to save general settings:', e));
}
function applyLinkArrows() {
document.documentElement.classList.toggle('no-link-arrows', !showLinkArrows);
}
function selectTheme(preset: typeof themePresets[0]) {
$theme = preset.id;
setTheme(preset.id);
themeDropdownOpen = false;
selectCustomAccent(preset.accent);
}
function selectAccent(preset: typeof accentPresets[0]) {
activeAccent = preset.name;
applyAccent(preset);
@@ -564,6 +637,23 @@
setAccentColor(accentName).catch((e) => console.error('Failed to save accent:', e));
}
function applyCustomAccent(hex: string) {
const root = document.documentElement;
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
root.style.setProperty('--accent', hex);
root.style.setProperty('--text-accent', hex);
root.style.setProperty('--accent-hover', hex);
root.style.setProperty('--accent-light', `color-mix(in srgb, ${hex} ${isDark ? '10%' : '8%'}, transparent)`);
}
function selectCustomAccent(hex: string) {
customAccentColor = hex;
activeAccent = hex;
applyCustomAccent(hex);
if ($appConfig) $appConfig.accent_color = hex;
setAccentColor(hex).catch((e) => console.error('Failed to save custom accent:', e));
}
function selectFontSize(size: number) {
activeFontSize = size;
applyFontSize(size);
@@ -612,6 +702,25 @@
}
}
function selectContentWidth(value: number | null) {
activeContentWidth = value;
applyContentWidth(value);
if ($appConfig) $appConfig.content_width = value;
setContentWidth(value).catch((e) => console.error('Failed to save content width:', e));
}
function applyContentWidth(value: number | null) {
document.documentElement.style.setProperty('--editor-content-width', value ? `${value}px` : 'none');
}
function clickOutside(node: HTMLElement, callback: () => void) {
function handle(e: MouseEvent) {
if (!node.contains(e.target as Node)) callback();
}
document.addEventListener('mousedown', handle, true);
return { destroy() { document.removeEventListener('mousedown', handle, true); } };
}
function close() {
// Ensure AI settings are saved when closing (in case input wasn't blurred)
// Only save if the key differs from what's already stored
@@ -625,12 +734,18 @@
$effect(() => {
const savedAccent = $appConfig?.accent_color;
if (savedAccent) {
if (savedAccent.startsWith('#')) {
activeAccent = savedAccent;
customAccentColor = savedAccent;
applyCustomAccent(savedAccent);
} else {
const preset = accentPresets.find(p => p.name === savedAccent);
if (preset) {
activeAccent = savedAccent;
applyAccent(preset);
}
}
}
const savedSize = $appConfig?.font_size;
if (savedSize) {
activeFontSize = savedSize;
@@ -649,6 +764,11 @@
activeLineHeight = savedLineHeight;
applyLineHeight(savedLineHeight);
}
const savedContentWidth = $appConfig?.content_width;
if (savedContentWidth) {
activeContentWidth = savedContentWidth;
applyContentWidth(savedContentWidth);
}
// Sync general settings
if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false;
@@ -656,11 +776,17 @@
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative';
weekStart = $appConfig.week_start ?? 'monday';
dailyTitleFormat = $appConfig.daily_title_format ?? 'localized';
gpuAcceleration = $appConfig.gpu_acceleration ?? true;
autostart = $appConfig.autostart ?? false;
pdfPreview = $appConfig.pdf_preview ?? false;
pdfHeight = $appConfig.pdf_height ?? 600;
titleMode = $appConfig.title_mode ?? 'input';
showAllNotes = $appConfig.show_all_notes ?? true;
showQuickAccess = $appConfig.show_quick_access ?? true;
showTasks = $appConfig.show_tasks ?? true;
showDailyNotes = $appConfig.show_daily_notes ?? true;
showTrash = $appConfig.show_trash ?? true;
}
});
@@ -784,6 +910,43 @@
</div>
</div>
{#if !isMobile}
<div class="settings-section">
<h3>Sidebar</h3>
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">All Notes</span></span>
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Tasks</span></span>
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Trash</span></span>
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
{/if}
<div class="settings-section">
<h3>Time Format</h3>
<div class="setting-options">
@@ -801,6 +964,17 @@
</div>
</div>
<div class="settings-section">
<h3>Daily note title format</h3>
<div class="setting-options">
<button class="option-btn" class:active={dailyTitleFormat === 'localized'} onclick={() => { dailyTitleFormat = 'localized'; saveGeneralSettings(); }}>Localized</button>
<button class="option-btn" class:active={dailyTitleFormat === 'iso'} onclick={() => { dailyTitleFormat = 'iso'; saveGeneralSettings(); }}>2026-06-10</button>
<button class="option-btn" class:active={dailyTitleFormat === 'long'} onclick={() => { dailyTitleFormat = 'long'; saveGeneralSettings(); }}>June 10, 2026</button>
<button class="option-btn" class:active={dailyTitleFormat === 'us'} onclick={() => { dailyTitleFormat = 'us'; saveGeneralSettings(); }}>06/10/2026</button>
<button class="option-btn" class:active={dailyTitleFormat === 'eu'} onclick={() => { dailyTitleFormat = 'eu'; saveGeneralSettings(); }}>10/06/2026</button>
</div>
</div>
{#if isMobile}
<div class="settings-section">
<h3>Vault</h3>
@@ -875,6 +1049,15 @@
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Show link arrows</span>
<span class="setting-desc">Display the arrow icon after external links</span>
</span>
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
<span class="toggle-knob"></span>
</button>
</label>
{#if !isMobile}
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
@@ -956,43 +1139,94 @@
</div>
{:else if activeTab === 'styling'}
<div class="tab-content">
<div class="two-col-sections">
<div class="settings-section">
<h3>Theme</h3>
<div class="theme-grid">
{#each themePresets as preset}
<div class="dropdown-wrap">
<button
class="theme-swatch"
class:active={$theme === preset.id}
onclick={() => { $theme = preset.id; setTheme(preset.id); }}
title={preset.label}
class="dropdown-trigger"
class:open={themeDropdownOpen}
onclick={() => { themeDropdownOpen = !themeDropdownOpen; accentDropdownOpen = false; }}
>
<span class="theme-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="dropdown-preview" style="--preview-bg: {activeThemePreset.bg}; --preview-sidebar: {activeThemePreset.sidebar}; --preview-accent: {activeThemePreset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
</span>
<span class="theme-label">{preset.label}</span>
<span class="dropdown-trigger-label">{activeThemePreset.label}</span>
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
{#if themeDropdownOpen}
<div class="dropdown-list" use:clickOutside={() => themeDropdownOpen = false}>
{#each themePresets as preset}
<button
class="dropdown-item"
class:active={$theme === preset.id}
onclick={() => selectTheme(preset)}
>
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
</span>
<span class="dropdown-item-label">{preset.label}</span>
{#if $theme === preset.id}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="settings-section">
<h3>Accent Color</h3>
<div class="accent-grid">
<div class="dropdown-wrap">
<button
class="dropdown-trigger"
class:open={accentDropdownOpen}
onclick={() => { accentDropdownOpen = !accentDropdownOpen; themeDropdownOpen = false; }}
>
<span class="accent-dot" style="background: {activeAccentColor}"></span>
<span class="dropdown-trigger-label">{isCustomAccent ? 'Custom' : activeAccentPreset.name}</span>
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
{#if accentDropdownOpen}
<div class="dropdown-list" use:clickOutside={() => accentDropdownOpen = false}>
{#each accentPresets as preset}
<button
class="accent-swatch"
class="dropdown-item"
class:active={activeAccent === preset.name}
style="--swatch-color: {isThemeDark ? preset.dark : preset.light}"
onclick={() => selectAccent(preset)}
title={preset.name}
onclick={() => { selectAccent(preset); accentDropdownOpen = false; }}
>
<span class="swatch-circle"></span>
<span class="swatch-label">{preset.name}</span>
<span class="accent-dot" style="background: {isThemeDark ? preset.dark : preset.light}"></span>
<span class="dropdown-item-label">{preset.name}</span>
{#if activeAccent === preset.name}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</button>
{/each}
<div class="dropdown-divider"></div>
<label class="dropdown-item custom-accent-item" class:active={isCustomAccent}>
<span class="accent-dot" style="background: {customAccentColor}"></span>
<span class="dropdown-item-label">Custom</span>
<input
type="color"
class="color-wheel-input"
value={customAccentColor}
oninput={(e) => applyCustomAccent(e.currentTarget.value)}
onchange={(e) => selectCustomAccent(e.currentTarget.value)}
/>
{#if isCustomAccent}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</label>
</div>
{/if}
</div>
</div>
</div>
@@ -1030,6 +1264,24 @@
</div>
{/if}
{#if !isMobile}
<div class="settings-section">
<h3>Note Width</h3>
<div class="font-size-options">
{#each contentWidthPresets as preset}
<button
class="font-size-btn"
class:active={activeContentWidth === preset.value}
onclick={() => selectContentWidth(preset.value)}
>
<span class="font-size-label">{preset.label}</span>
</button>
{/each}
</div>
<p class="setting-hint">Caps the width of the note text for easier reading on wide screens; the column stays centered. Most noticeable in Focus Mode. "Full" uses the entire width.</p>
</div>
{/if}
<div class="settings-section">
<h3>Line Height</h3>
<div class="font-size-options">
@@ -1244,7 +1496,7 @@
<button class="option-btn" class:active={aiProvider === 'ollama'} onclick={() => { if (aiProvider === 'ollama') return; aiProvider = 'ollama'; aiModel = 'gemma3:4b'; aiTestMessage = null; saveAiSettings(); }}>Ollama</button>
<button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-6'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button>
<button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.5'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button>
<button class="option-btn" class:active={aiProvider === 'openai_compatible'} onclick={() => { if (aiProvider === 'openai_compatible') return; aiProvider = 'openai_compatible'; aiModel = ''; aiTestMessage = null; saveAiSettings(); }}>Compatible</button>
<button class="option-btn" class:active={aiProvider === 'openai_compatible'} onclick={() => { if (aiProvider === 'openai_compatible') return; aiProvider = 'openai_compatible'; aiModel = ''; aiTestMessage = null; saveAiSettings(); }}>OpenAI Compatible</button>
</div>
</div>
@@ -1299,7 +1551,7 @@
oninput={(e) => { _openaiCompatibleBaseUrl = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings}
/>
<p class="setting-hint">Base URL for any OpenAI-compatible server (LM Studio, LocalAI, vLLM, etc.). Uses the <code>v1/completions</code> endpoint.</p>
<p class="setting-hint">Base URL for any OpenAI-compatible server (LM Studio, LocalAI, vLLM, Nvidia NIM, etc.). Enter the server root; the app appends <code>/v1/chat/completions</code> (a trailing <code>/v1</code> is fine too).</p>
</div>
<div class="settings-section">
<h3>API Key <span style="font-weight: 400; text-transform: none; font-size: 11px;">(optional)</span></h3>
@@ -1546,6 +1798,12 @@
<p class="update-version">HelixNotes <strong>v{appVersion}</strong></p>
</div>
{#if isManagedInstall($installType)}
<div class="settings-section">
<h3>Updates</h3>
<p class="setting-hint">HelixNotes was installed through your system package manager, which delivers updates. The app does not check for or install updates on its own.</p>
</div>
{:else}
<div class="settings-section">
<h3>Check for Updates</h3>
<button class="import-btn" onclick={handleCheckUpdate} disabled={updateChecking || updateDownloading}>
@@ -1560,6 +1818,7 @@
{/if}
</button>
</div>
{/if}
{#if updateAvailable}
<div class="settings-section">
@@ -1868,42 +2127,116 @@
font-weight: 500;
}
.theme-grid {
.two-col-sections {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.theme-swatch {
.two-col-sections .settings-section {
margin-bottom: 24px;
}
.dropdown-wrap {
position: relative;
}
.dropdown-trigger {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 6px;
gap: 10px;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
cursor: pointer;
transition: all 0.15s;
transition: border-color 0.15s;
}
.theme-swatch:hover {
border-color: var(--text-tertiary);
}
.theme-swatch.active {
.dropdown-trigger:hover,
.dropdown-trigger.open {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-light);
}
.theme-preview {
display: flex;
.dropdown-trigger-label {
flex: 1;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.dropdown-chevron {
width: 14px;
height: 14px;
color: var(--text-tertiary);
flex-shrink: 0;
transition: transform 0.15s;
}
.dropdown-trigger.open .dropdown-chevron {
transform: rotate(180deg);
}
.dropdown-list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-md);
z-index: 100;
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.dropdown-item {
width: 100%;
height: 32px;
display: flex;
align-items: center;
gap: 10px;
padding: 7px 8px;
border-radius: 5px;
background: none;
border: none;
cursor: pointer;
transition: background 0.1s;
}
.dropdown-item:hover {
background: var(--bg-hover);
}
.dropdown-item.active {
background: var(--accent-light);
}
.dropdown-item-label {
flex: 1;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.dropdown-check {
width: 14px;
height: 14px;
color: var(--accent);
flex-shrink: 0;
}
.dropdown-preview {
display: flex;
width: 36px;
height: 22px;
border-radius: 4px;
overflow: hidden;
background: var(--preview-bg);
border: 1px solid rgba(0,0,0,0.1);
border: 1px solid rgba(0,0,0,0.12);
flex-shrink: 0;
}
.preview-sidebar {
@@ -1915,10 +2248,10 @@
.preview-main {
flex: 1;
background: var(--preview-bg);
padding: 4px;
padding: 3px;
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
}
.preview-accent-bar {
@@ -1928,62 +2261,33 @@
border-radius: 2px;
}
.theme-label {
font-size: 10px;
color: var(--text-secondary);
text-align: center;
line-height: 1.2;
word-break: break-word;
}
.theme-swatch.active .theme-label {
color: var(--accent);
}
.accent-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.accent-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
cursor: pointer;
transition: all 0.15s;
}
.accent-swatch:hover {
background: var(--bg-hover);
border-color: var(--swatch-color);
}
.accent-swatch.active {
border-color: var(--swatch-color);
background: var(--accent-light);
}
.swatch-circle {
width: 24px;
height: 24px;
.accent-dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--swatch-color);
flex-shrink: 0;
}
.swatch-label {
font-size: 11px;
color: var(--text-secondary);
.dropdown-divider {
height: 1px;
background: var(--border-light);
margin: 4px 0;
}
.accent-swatch.active .swatch-label {
color: var(--text-primary);
font-weight: 500;
.custom-accent-item {
cursor: pointer;
position: relative;
}
.color-wheel-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: none;
padding: 0;
}
.font-size-options {
@@ -2652,15 +2956,6 @@
font-size: 14px;
}
.settings-panel.mobile .accent-grid {
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.settings-panel.mobile .accent-swatch {
padding: 14px 8px;
}
.settings-panel.mobile .font-size-options {
gap: 8px;
}
+208 -30
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import {
notebooks,
notes,
@@ -20,7 +21,7 @@
notebookSortMode,
notebookOrder
} from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core';
@@ -33,10 +34,22 @@
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
const anyNavItem = $derived(
$appConfig?.show_all_notes !== false ||
$appConfig?.show_quick_access !== false ||
$appConfig?.show_tasks !== false ||
$appConfig?.show_daily_notes !== false ||
$appConfig?.show_trash !== false
);
let editingNotebook = $state<string | null>(null);
let editValue = $state('');
let renameInput: HTMLInputElement | null = $state(null);
let newNotebookName = $state('');
let showNewNotebook = $state(false);
let newNotebookInput: HTMLInputElement | null = $state(null);
let dropTargetPath = $state<string | null>(null);
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
let draggedNotebookPath = $state<string | null>(null);
@@ -59,15 +72,37 @@
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null);
function toggleCollapse(path: string, e: MouseEvent) {
e.stopPropagation();
if ($collapsedNotebooks.includes(path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== path);
} else {
$collapsedNotebooks = [...$collapsedNotebooks, path];
function expandNotebook(nb: NotebookEntry) {
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
}
}
function toggleCollapse(nb: NotebookEntry, e: Event) {
e.stopPropagation();
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
} else {
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
}
}
// Collapse-all / expand-all: toggles every notebook that has children.
function collectParentPaths(tree: NotebookEntry[]): string[] {
const out: string[] = [];
for (const nb of tree) {
if (nb.children.length > 0) {
out.push(nb.path);
out.push(...collectParentPaths(nb.children));
}
}
return out;
}
const allParentPaths = $derived(collectParentPaths($notebooks));
const allCollapsed = $derived(allParentPaths.length > 0 && allParentPaths.every((p) => $collapsedNotebooks.includes(p)));
function toggleAllCollapse() {
$collapsedNotebooks = allCollapsed ? [] : [...allParentPaths];
}
export async function refresh() {
try {
@@ -117,12 +152,23 @@
onViewChanged();
}
function selectNotebook(nb: NotebookEntry) {
async function selectNotebook(nb: NotebookEntry) {
const hasVisibleChildren = nb.children.length > 0;
const wasCollapsed = $collapsedNotebooks.includes(nb.path);
// Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
if (hasVisibleChildren) {
if (wasCollapsed) {
await expandNotebook(nb);
} else {
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
}
return;
}
selectAllNotes();
return;
}
if (hasVisibleChildren) await expandNotebook(nb);
$viewMode = 'notebook';
$activeNotebook = nb;
$activeTag = null;
@@ -166,11 +212,52 @@
let newNotebookParent = $state<NotebookEntry | null>(null);
function getNotebookNameSegments(name: string): string[] | null {
const segments = name.split('/').map((segment) => segment.trim());
if (segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) {
return null;
}
return segments;
}
async function focusNewNotebookInput() {
await tick();
newNotebookInput?.focus();
}
async function toggleNewNotebookInput() {
if (showNewNotebook) {
showNewNotebook = false;
newNotebookName = '';
newNotebookParent = null;
return;
}
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
await focusNewNotebookInput();
}
async function startNewNotebookFromSidebar(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .context-menu, .delete-confirm-overlay')) return;
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
newNotebookName = '';
showNewNotebook = true;
await focusNewNotebookInput();
}
async function handleCreateNotebook() {
if (!newNotebookName.trim()) return;
try {
const trimmedName = newNotebookName.trim();
const parentRel = newNotebookParent?.relative_path ?? null;
await createNotebook(parentRel, newNotebookName.trim());
const name = parentRel ? trimmedName : getNotebookNameSegments(trimmedName)?.join('/');
if (!name) return;
await createNotebook(parentRel, name);
newNotebookName = '';
showNewNotebook = false;
newNotebookParent = null;
@@ -180,7 +267,24 @@
}
}
function startNewSubNotebook(nb: NotebookEntry) {
function handleNewNotebookKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleCreateNotebook();
return;
}
if (e.key === 'Escape') {
showNewNotebook = false;
newNotebookName = '';
newNotebookParent = null;
return;
}
if (e.key === 'Backspace' && newNotebookParent && newNotebookName.length === 0) {
e.preventDefault();
newNotebookParent = null;
}
}
async function startNewSubNotebook(nb: NotebookEntry) {
contextMenu = null;
newNotebookParent = nb;
newNotebookName = '';
@@ -189,6 +293,7 @@
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
}
await focusNewNotebookInput();
}
async function handleRename(nb: NotebookEntry) {
@@ -378,10 +483,13 @@
}
}
function startRename(nb: NotebookEntry) {
async function startRename(nb: NotebookEntry) {
contextMenu = null;
editingNotebook = nb.path;
editValue = nb.name;
await tick();
renameInput?.focus();
renameInput?.select();
}
async function handleSetIcon(nb: NotebookEntry) {
@@ -470,7 +578,7 @@
<svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}>
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem} ondblclick={startNewNotebookFromSidebar}>
{#if !isMobile}
<div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
@@ -482,19 +590,19 @@
{/if}
</svg>
</button>
{#if !$sidebarCollapsed}
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
{/if}
</div>
{/if}
{#if !$sidebarCollapsed}
{#if anyNavItem}
<nav class="sidebar-nav">
{#if $appConfig?.show_all_notes !== false}
<button
class="nav-item"
class:active={$viewMode === 'all'}
@@ -506,7 +614,9 @@
</svg>
<span>All Notes</span>
</button>
{/if}
{#if $appConfig?.show_quick_access !== false}
<button
class="nav-item"
class:active={$viewMode === 'quickaccess'}
@@ -517,7 +627,9 @@
</svg>
<span>Quick Access</span>
</button>
{/if}
{#if $appConfig?.show_tasks !== false}
<button
class="nav-item"
class:active={$viewMode === 'tasks'}
@@ -529,7 +641,9 @@
</svg>
<span>Tasks</span>
</button>
{/if}
{#if $appConfig?.show_daily_notes !== false}
<button
class="nav-item"
class:active={$viewMode === 'daily'}
@@ -543,7 +657,9 @@
</svg>
<span>Daily Notes</span>
</button>
{/if}
{#if $appConfig?.show_trash !== false}
<button
class="nav-item"
class:active={$viewMode === 'trash'}
@@ -555,7 +671,9 @@
</svg>
<span>Trash</span>
</button>
{/if}
</nav>
{/if}
<div class="section">
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -578,20 +696,25 @@
}}
>
<span class="section-title">Notebooks</span>
<button class="icon-btn-sm" onclick={() => {
if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else {
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
}
}} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<div class="section-actions">
{#if allParentPaths.length > 0}
<button class="icon-btn-sm" onclick={(e) => { e.stopPropagation(); toggleAllCollapse(); }} title={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'} aria-label={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if allCollapsed}
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
{:else}
<path d="m7 20 5-5 5 5" /><path d="m7 4 5 5 5-5" />
{/if}
</svg>
</button>
{/if}
<button class="icon-btn-sm" onclick={toggleNewNotebookInput} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
</div>
{#if showNewNotebook}
<div class="new-notebook-input">
@@ -601,13 +724,11 @@
</button>
{/if}
<input
bind:this={newNotebookInput}
type="text"
bind:value={newNotebookName}
placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
onkeydown={(e) => {
if (e.key === 'Enter') handleCreateNotebook();
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; }
}}
onkeydown={handleNewNotebookKeydown}
/>
</div>
{/if}
@@ -679,6 +800,7 @@
{/if}
</div>
{/if}
{/if}
<div class="sidebar-footer">
<button class="icon-btn" onclick={() => ($showInfo = true)} title="Info">
@@ -698,7 +820,6 @@
</div>
</div>
{/if}
</aside>
{#if contextMenu}
@@ -765,6 +886,7 @@
<input
type="text"
class="rename-input"
bind:this={renameInput}
bind:value={editValue}
onkeydown={(e) => {
if (e.key === 'Enter') handleRename(nb);
@@ -783,6 +905,12 @@
style="padding-left: {8 + depth * 16}px"
draggable="true"
onclick={() => selectNotebook(nb)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(nb);
}
}}
oncontextmenu={(e) => onContextMenu(e, nb)}
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
@@ -814,8 +942,14 @@
ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
ondrop={(e) => {
e.preventDefault();
const pos = dropPosition;
if (draggedNotebookPath) {
// Recompute the drop position from the drop coordinates, not the dragover-set
// state: dragleave over the row's child nodes (chevron/icon/label) clears
// dropPosition before drop (esp. Windows WebView2), which mis-routed every drop
// to "nest" and broke reorder + un-nest. (issue #97)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const relY = (e.clientY - rect.top) / rect.height;
const pos = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
if (pos === 'above' || pos === 'below') {
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
} else {
@@ -828,7 +962,7 @@
>
{#if hasChildren}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb.path, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb.path, e); }}>
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb, e); }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
@@ -871,6 +1005,29 @@
max-width: 44px;
}
/* Collapsed rail: stack the toolbar icons vertically - search under the expand chevron at
the top, info + settings pinned to the bottom. */
.sidebar.collapsed .sidebar-header {
flex-direction: column;
justify-content: flex-start;
gap: 4px;
padding: 8px 4px;
border-bottom: none;
}
.sidebar.collapsed .sidebar-footer {
flex-direction: column;
align-items: center;
gap: 4px;
margin-top: auto;
padding: 8px 4px;
border-top: none;
}
.sidebar.collapsed .sidebar-footer-right {
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
@@ -914,6 +1071,22 @@
flex: 1;
overflow-y: auto;
padding: 4px 0;
border-top: 1px solid var(--border-light);
}
/* With the top nav hidden, the header's border-bottom is the only divider needed -
drop the section's border-top so there's no doubled line under the header. */
.sidebar.nav-empty .section {
border-top: none;
}
/* Pin the Notebooks header so its collapse/expand-all button stays reachable while the tree
scrolls; the section's border-top draws the divider above it, matching the Tags section. (issue #126) */
.section > .section-header {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-secondary);
}
.tags-section {
@@ -946,6 +1119,11 @@
color: var(--text-tertiary);
}
.section-actions {
display: flex;
align-items: center;
gap: 2px;
}
.icon-btn, .icon-btn-sm {
background: none;
border: none;
+31 -11
View File
@@ -3,7 +3,7 @@
import { listen } from '@tauri-apps/api/event';
import { getTasks } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import { tasksLayout, appConfig } from '$lib/stores/app';
import { tasksLayout, tasksHideCompleted, tasksOnlyFlagged, tasksSort, appConfig } from '$lib/stores/app';
import { isAndroid } from '$lib/platform';
import type { TaskItem, FileEvent } from '$lib/types';
@@ -16,8 +16,7 @@
let tasks = $state<TaskItem[]>([]);
let loading = $state(true);
let hideCompleted = $state(true);
let sortBy = $state<'due' | 'priority' | 'note'>('due');
let taskQuery = $state('');
let unlisten: (() => void) | null = null;
async function load() {
@@ -43,16 +42,24 @@
const prioRank: Record<string, number> = { high: 0, med: 1, low: 2 };
function prank(p: string | null) { return p ? (prioRank[p] ?? 3) : 3; }
// Free-text task filter: matches the task text or its note title (case-insensitive).
function matchesQuery(t: TaskItem): boolean {
const q = taskQuery.trim().toLowerCase();
if (!q) return true;
return t.text.toLowerCase().includes(q) || t.note_title.toLowerCase().includes(q);
}
const filtered = $derived.by(() => {
let list = tasks.slice();
if (hideCompleted) list = list.filter((t) => !t.completed);
if ($tasksHideCompleted) list = list.filter((t) => !t.completed);
if ($tasksOnlyFlagged) list = list.filter((t) => !!t.due || !!t.priority);
list = list.filter(matchesQuery);
list.sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
if (sortBy === 'priority') {
if ($tasksSort === 'priority') {
const d = prank(a.priority) - prank(b.priority);
if (d) return d;
} else if (sortBy === 'note') {
} else if ($tasksSort === 'note') {
const d = a.note_title.localeCompare(b.note_title);
if (d) return d;
} else {
@@ -110,7 +117,7 @@
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
// Calendar tasks share the list's hide-completed filter (no sort).
const calBase = $derived(tasks.filter((t) => !hideCompleted || !t.completed));
const calBase = $derived(tasks.filter((t) => (!$tasksHideCompleted || !t.completed) && (!$tasksOnlyFlagged || !!t.due || !!t.priority) && matchesQuery(t)));
const tasksByDate = $derived.by(() => {
const m = new Map<string, TaskItem[]>();
@@ -186,7 +193,9 @@
<button class="prio-backdrop" onclick={() => { prioMenuFor = null; dueMenuFor = null; }} aria-label="Close menu"></button>
{/if}
<div class="tasks-controls">
<button class="tasks-ctl" class:active={hideCompleted} onclick={() => (hideCompleted = !hideCompleted)} title="Hide completed tasks">Hide done</button>
<input class="tasks-filter" type="text" placeholder="Filter tasks…" bind:value={taskQuery} onkeydown={(e) => { if (e.key === 'Escape') taskQuery = ''; }} aria-label="Filter tasks by text" />
<button class="tasks-ctl" class:active={$tasksHideCompleted} onclick={() => ($tasksHideCompleted = !$tasksHideCompleted)} title="Hide completed tasks">Hide done</button>
<button class="tasks-ctl" class:active={$tasksOnlyFlagged} onclick={() => ($tasksOnlyFlagged = !$tasksOnlyFlagged)} title="Only show tasks with a due date or priority">Flagged</button>
{#if !isAndroid}
<div class="tasks-layout">
<button class="tasks-ctl" class:active={viewLayout === 'list'} onclick={() => ($tasksLayout = 'list')} title="List view">List</button>
@@ -196,7 +205,7 @@
{#if viewLayout === 'list'}
<div class="tasks-sort">
{#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]}
<button class="tasks-ctl" class:active={sortBy === v} onclick={() => (sortBy = v as 'due' | 'priority' | 'note')}>{label}</button>
<button class="tasks-ctl" class:active={$tasksSort === v} onclick={() => ($tasksSort = v as 'due' | 'priority' | 'note')}>{label}</button>
{/each}
</div>
{/if}
@@ -366,6 +375,17 @@
color: #fff;
border-color: var(--accent);
}
.tasks-filter {
width: 150px;
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
}
.tasks-filter::placeholder { color: var(--text-tertiary); }
.tasks-filter:focus { outline: none; border-color: var(--accent); }
.tasks-list {
flex: 1;
overflow-y: auto;
@@ -458,7 +478,7 @@
background: transparent;
color: var(--text-tertiary);
border: 1px dashed var(--border-color);
opacity: 0;
opacity: 0.4;
transition: opacity 0.1s;
}
.task-row:hover .task-prio.prio-none { opacity: 1; }
@@ -529,7 +549,7 @@
background: transparent;
border-style: dashed;
color: var(--text-tertiary);
opacity: 0;
opacity: 0.4;
transition: opacity 0.1s;
}
.task-row:hover .task-due:not(.set) { opacity: 1; }
+80 -3
View File
@@ -2,7 +2,7 @@
import { open } from '@tauri-apps/plugin-dialog';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { documentDir } from '@tauri-apps/api/path';
import { openVault, getAppConfig } from '$lib/api';
import { openVault, removeVault, getAppConfig } from '$lib/api';
import { appConfig, vaultReady } from '$lib/stores/app';
import { isAndroid, isIOS, isMobile } from '$lib/platform';
import type { VaultConfig } from '$lib/types';
@@ -107,6 +107,15 @@
loading = false;
}
}
async function forgetVault(path: string) {
try {
await removeVault(path);
$appConfig = await getAppConfig();
} catch (e) {
error = String(e);
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -205,10 +214,18 @@
<div class="recent">
<span class="recent-label">Recent</span>
{#each recentVaults as vault}
<button class="vault-item" onclick={() => openSelectedVault(vault.path)}>
<span class="vault-name">{vault.name}</span>
<div class="vault-row">
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}>
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span>
<span class="vault-path">{vault.path}</span>
</button>
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
<svg width="12" height="12" viewBox="0 0 10 10">
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
@@ -372,6 +389,24 @@
background: var(--bg-hover);
}
.vault-item.current {
border-color: var(--accent);
}
.vault-current-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
padding: 2px 7px;
border-radius: 10px;
margin-left: 8px;
vertical-align: middle;
}
.vault-name {
display: block;
font-weight: 500;
@@ -389,6 +424,48 @@
white-space: nowrap;
}
.vault-row {
display: flex;
align-items: stretch;
gap: 6px;
margin-bottom: 6px;
}
.vault-row .vault-item {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.vault-remove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.vault-row:hover .vault-remove,
.vault-remove:focus-visible {
opacity: 1;
}
.vault-remove:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vault-picker.mobile .vault-remove {
opacity: 1;
}
.vault-name-input {
margin-bottom: 20px;
text-align: left;
@@ -0,0 +1,30 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { getSelectionPair } from '../selectionPairs';
// TipTap/ProseMirror behavior for wrapping a non-empty rich-editor selection
// without flattening selected inline marks or content to plain text.
export const WrapSelectedText = Extension.create({
name: 'wrapSelectedText',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('wrapSelectedText'),
props: {
// Intercept only supported single-character text input over a range.
// Everything else falls back to ProseMirror's normal input handling.
handleTextInput(view, from, to, text) {
const close = getSelectionPair(text);
if (!close || from === to) return false;
const tr = view.state.tr.insertText(close, to).insertText(text, from);
tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
view.dispatch(tr);
return true;
},
},
}),
];
},
});
+15
View File
@@ -0,0 +1,15 @@
// Shared source of truth for editor behaviors that wrap an active selection.
// Keep this small: only characters users reasonably expect to form pairs.
const selectionPairs: Record<string, string> = {
'(': ')',
'{': '}',
'[': ']',
"'": "'",
'"': '"',
'`': '`',
};
// Returns the closing character for a typed wrapper key, or null to pass through.
export function getSelectionPair(key: string) {
return selectionPairs[key] ?? null;
}
+14
View File
@@ -0,0 +1,14 @@
import { getSelectionPair } from '../selectionPairs';
// Pure source-mode helper: computes the wrapped textarea value and preserved
// inner selection so Svelte event handlers stay as glue code.
export function wrapTextareaSelection(value: string, start: number, end: number, key: string) {
const close = getSelectionPair(key);
if (!close || start === end) return null;
return {
value: value.slice(0, start) + key + value.slice(start, end) + close + value.slice(end),
selectionStart: start + key.length,
selectionEnd: end + key.length,
};
}
+164
View File
@@ -0,0 +1,164 @@
import { writable } from "svelte/store";
// A single key combination. `mod` means Ctrl on Windows/Linux and Cmd on macOS.
// `code` is a KeyboardEvent.code value (layout-independent), e.g. "KeyN",
// "Backslash", "ArrowLeft", "F11".
export interface Binding {
mod?: boolean;
shift?: boolean;
alt?: boolean;
code: string;
}
export interface ActionDef {
id: string;
label: string;
group: "General" | "Interface" | "Navigation";
default: Binding;
}
// Every customizable action and its factory-default binding. These mirror the
// handlers in AppLayout.handleKeydown and the rows in InfoPanel's Shortcuts tab.
export const ACTIONS: ActionDef[] = [
{ id: "new-note", label: "New note", group: "General", default: { mod: true, code: "KeyN" } },
{ id: "save", label: "Save", group: "General", default: { mod: true, code: "KeyS" } },
{ id: "quick-open", label: "Quick open", group: "General", default: { mod: true, code: "KeyP" } },
{ id: "find-in-note", label: "Find in note", group: "General", default: { mod: true, code: "KeyF" } },
{ id: "search-vault", label: "Search vault", group: "General", default: { mod: true, shift: true, code: "KeyF" } },
{ id: "open-new-window", label: "Open note in new window", group: "General", default: { mod: true, shift: true, code: "KeyW" } },
{ id: "toggle-sidebar", label: "Toggle sidebar", group: "Interface", default: { mod: true, code: "Backslash" } },
{ id: "toggle-notelist", label: "Toggle notes list", group: "Interface", default: { mod: true, shift: true, code: "Backslash" } },
{ id: "toggle-theme", label: "Toggle dark / light theme", group: "Interface", default: { mod: true, shift: true, code: "KeyN" } },
{ id: "toggle-focus", label: "Toggle focus mode", group: "Interface", default: { mod: true, shift: true, code: "KeyE" } },
{ id: "toggle-readonly", label: "Toggle read-only", group: "Interface", default: { mod: true, shift: true, code: "KeyR" } },
{ id: "toggle-source", label: "Toggle source / WYSIWYG", group: "Interface", default: { mod: true, shift: true, code: "KeyM" } },
{ id: "fullscreen", label: "Toggle fullscreen", group: "Interface", default: { code: "F11" } },
{ id: "nav-back", label: "Go back", group: "Navigation", default: { alt: true, code: "ArrowLeft" } },
{ id: "nav-forward", label: "Go forward", group: "Navigation", default: { alt: true, code: "ArrowRight" } },
];
export const DEFAULT_BINDINGS: Record<string, Binding> = Object.fromEntries(
ACTIONS.map((a) => [a.id, a.default]),
);
const STORAGE_KEY = "helix-keybindings";
function loadBindings(): Record<string, Binding> {
const merged: Record<string, Binding> = { ...DEFAULT_BINDINGS };
try {
const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY);
if (raw) {
const stored = JSON.parse(raw) as Record<string, Binding>;
for (const id of Object.keys(merged)) {
if (stored[id]) merged[id] = stored[id];
}
}
} catch {
// Corrupt storage - fall back to defaults.
}
return merged;
}
export const keybindings = writable<Record<string, Binding>>(loadBindings());
keybindings.subscribe((map) => {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
}
} catch {
// Ignore quota / unavailable storage.
}
});
export function setBinding(id: string, binding: Binding) {
keybindings.update((m) => ({ ...m, [id]: binding }));
}
export function resetBinding(id: string) {
keybindings.update((m) => ({ ...m, [id]: DEFAULT_BINDINGS[id] }));
}
// True when the keyboard event matches the given binding exactly. Modifier
// state must match precisely so e.g. Mod+Shift+F never also triggers Mod+F.
export function matchBinding(e: KeyboardEvent, b: Binding | undefined): boolean {
if (!b) return false;
const mod = e.ctrlKey || e.metaKey;
return (
!!b.mod === mod &&
!!b.shift === e.shiftKey &&
!!b.alt === e.altKey &&
e.code === b.code
);
}
// Returns the id of the first action whose binding matches the event, or null.
export function matchAction(e: KeyboardEvent, map: Record<string, Binding>): string | null {
for (const id of Object.keys(map)) {
if (matchBinding(e, map[id])) return id;
}
return null;
}
// Build a Binding from a captured keydown event. Returns null for events that
// are only modifier keys (no real key pressed yet).
const MODIFIER_CODES = new Set([
"ControlLeft", "ControlRight", "MetaLeft", "MetaRight",
"ShiftLeft", "ShiftRight", "AltLeft", "AltRight",
]);
export function bindingFromEvent(e: KeyboardEvent): Binding | null {
if (MODIFIER_CODES.has(e.code) || !e.code) return null;
return {
mod: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
alt: e.altKey,
code: e.code,
};
}
export function bindingsEqual(a: Binding, b: Binding): boolean {
return !!a.mod === !!b.mod && !!a.shift === !!b.shift && !!a.alt === !!b.alt && a.code === b.code;
}
const isMac = typeof navigator !== "undefined" && navigator.platform.startsWith("Mac");
const CODE_LABELS: Record<string, string> = {
Backslash: "\\",
BracketLeft: "[",
BracketRight: "]",
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
Backquote: "`",
Minus: "-",
Equal: "=",
Space: "Space",
Enter: "Enter",
Tab: "Tab",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
};
function codeToLabel(code: string): string {
if (code.startsWith("Key")) return code.slice(3);
if (code.startsWith("Digit")) return code.slice(5);
if (code.startsWith("Numpad")) return code.slice(6);
return CODE_LABELS[code] ?? code;
}
// Ordered list of key labels for rendering, e.g. ["Ctrl", "Shift", "N"].
export function bindingToKeys(b: Binding): string[] {
const keys: string[] = [];
if (b.mod) keys.push(isMac ? "⌘" : "Ctrl");
if (b.alt) keys.push(isMac ? "⌥" : "Alt");
if (b.shift) keys.push(isMac ? "⇧" : "Shift");
keys.push(codeToLabel(b.code));
return keys;
}
+34
View File
@@ -5,3 +5,37 @@ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
export const isAndroid = /android/i.test(ua);
export const isIOS = /iphone|ipad|ipod/i.test(ua);
export const isMobile = isAndroid || isIOS;
// Themes that use the dark color scheme. Used by applyTheme() to toggle the
// `dark` class on the root element. Keep this in sync when adding new themes.
export const darkThemes = [
'dark',
'solarized-dark',
'catppuccin',
'nord',
'tokyo-night',
'github-dark',
'dracula',
'blueberry',
'forest-green',
'gruvbox',
'midnight-tide',
'cherry-blossom',
'synthwave',
'ember',
'moonlit',
'dark-coffee',
'crimson',
'material-dark',
'monokai',
'rose-pine',
'everforest',
'horizon',
'cyberpunk',
'black',
'one-dark',
];
export function isDarkTheme(theme: string): boolean {
return darkThemes.includes(theme) || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
+41 -2
View File
@@ -17,7 +17,11 @@ export const vaultReady = writable(false);
export const viewMode = writable<ViewMode>("all");
export const sortMode = writable<SortMode>("modified");
export const tasksLayout = writable<"list" | "calendar">("list");
export const tasksHideCompleted = writable<boolean>(true);
export const tasksOnlyFlagged = writable<boolean>(false);
export const tasksSort = writable<"due" | "priority" | "note">("due");
export const sidebarCollapsed = writable(false);
export const notelistCollapsed = writable(false);
export const sidebarWidth = writable(220);
export const notelistWidth = writable(280);
export const searchQuery = writable("");
@@ -36,6 +40,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">(
);
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
export const notebookOrder = writable<Record<string, number>>({});
export const noteOrder = writable<Record<string, number>>({});
// Data
export const notebooks = writable<NotebookEntry[]>([]);
@@ -82,8 +87,22 @@ export const updateAvailable = writable<{
} | null>(null);
export const updateObj = writable<any>(null);
export const installType = writable<string>("native");
// True only on the Android/iOS build. Defaults to a user-agent guess for the first paint, then
// gets the authoritative compile-time value from the backend at startup (see +layout). (#63)
export const platformIsMobile = writable<boolean>(
typeof navigator !== "undefined" && /android|iphone|ipad|ipod/i.test(navigator.userAgent),
);
export const androidApkUrl = writable<string | null>(null);
// Install types that handle their own updates (in-app auto-updater, or a
// package-manager notice for deb/aur). Anything else - e.g. a distro repo build
// that sets the HELIXNOTES_INSTALL_TYPE build flag (Solus, etc.) - is "managed":
// the app does no update check and shows no update UI at all.
const SELF_UPDATING_INSTALL_TYPES = ["macos", "windows", "deb", "aur", "appimage", "native", "android"];
export function isManagedInstall(type: string): boolean {
return !SELF_UPDATING_INSTALL_TYPES.includes(type);
}
export async function checkForUpdate() {
try {
const { check } = await import("@tauri-apps/plugin-updater");
@@ -159,16 +178,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length
// Derived
export const sortedNotes = derived(
[notes, sortMode, viewMode],
([$notes, $sortMode, $viewMode]) => {
[notes, sortMode, viewMode, noteOrder],
([$notes, $sortMode, $viewMode, $noteOrder]) => {
// Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes;
const customSortable =
$viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag";
const pinned = $notes.filter((n) => n.meta.pinned);
const unpinned = $notes.filter((n) => !n.meta.pinned);
const sortFn = (a: NoteEntry, b: NoteEntry) => {
switch ($sortMode) {
case "custom": {
if (!customSortable) {
return (
new Date(b.meta.modified).getTime() -
new Date(a.meta.modified).getTime()
);
}
const oa = $noteOrder[a.path] ?? Number.MAX_SAFE_INTEGER;
const ob = $noteOrder[b.path] ?? Number.MAX_SAFE_INTEGER;
if (oa !== ob) return oa - ob;
return a.meta.title.localeCompare(b.meta.title);
}
case "title":
return a.meta.title.localeCompare(b.meta.title);
case "created":
@@ -195,27 +228,33 @@ export const vaultState = derived(
sidebarWidth,
notelistWidth,
sidebarCollapsed,
notelistCollapsed,
collapsedNotebooks,
notebookSortMode,
notebookOrder,
noteOrder,
],
([
$activeNotePath,
$sidebarWidth,
$notelistWidth,
$sidebarCollapsed,
$notelistCollapsed,
$collapsedNotebooks,
$notebookSortMode,
$notebookOrder,
$noteOrder,
]) => {
return {
last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
note_order: $noteOrder,
} satisfies VaultState;
},
);
+14 -1
View File
@@ -55,10 +55,12 @@ export interface AppConfig {
font_family: string | null;
line_height: number | null;
ui_scale: number | null;
content_width: number | null;
compact_notes: boolean;
show_note_dates: boolean;
time_format: string;
week_start: string;
daily_title_format: string;
gpu_acceleration: boolean;
autostart: boolean;
pdf_preview: boolean;
@@ -66,6 +68,12 @@ export interface AppConfig {
title_mode: string;
hide_title_in_body: boolean;
show_line_numbers: boolean;
show_link_arrows: boolean;
show_all_notes: boolean;
show_quick_access: boolean;
show_tasks: boolean;
show_daily_notes: boolean;
show_trash: boolean;
backup_enabled: boolean;
backup_frequency: string;
backup_max_count: number;
@@ -102,14 +110,19 @@ export interface VaultState {
sidebar_width: number;
notelist_width: number;
sidebar_collapsed: boolean;
notelist_collapsed: boolean;
collapsed_notebooks: string[];
notebook_sort_mode?: string;
notebook_order?: Record<string, number>;
note_order?: Record<string, number>;
sort_mode?: string;
last_view_mode?: string;
last_notebook?: string | null;
last_tag?: string | null;
tasks_layout?: string;
tasks_hide_completed?: boolean;
tasks_only_flagged?: boolean;
tasks_sort?: string;
}
export interface SearchResult {
@@ -160,7 +173,7 @@ export interface NoteTitleEntry {
path: string;
}
export type SortMode = "modified" | "title" | "created";
export type SortMode = "modified" | "title" | "created" | "custom";
export type ViewMode =
| "all"
| "notebook"
+150
View File
@@ -0,0 +1,150 @@
const SECRET_VERSION = 2;
const SECRET_CIPHER = 'AES-256-GCM';
const SECRET_KDF = 'PBKDF2-HMAC-SHA256';
const SECRET_ITERATIONS = 600_000;
const SALT_BYTES = 16;
const NONCE_BYTES = 12;
interface SecretEnvelope {
v: number;
title?: string;
cipher: string;
kdf: string;
iterations: number;
salt: string;
nonce: string;
ct: string;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string, field: string): Uint8Array {
const text = value.trim();
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) {
throw new Error(`Invalid secret ${field}`);
}
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function stableBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy;
}
function authenticatedMetadata(envelope: SecretEnvelope): string {
return JSON.stringify({
v: envelope.v,
title: envelope.title ?? 'Encrypted secret',
cipher: envelope.cipher,
kdf: envelope.kdf,
iterations: envelope.iterations,
salt: envelope.salt,
nonce: envelope.nonce,
});
}
function envelopeAad(envelope: SecretEnvelope): Uint8Array {
return encoder.encode(authenticatedMetadata(envelope));
}
function parseEnvelope(payload: string): SecretEnvelope {
let envelope: SecretEnvelope;
try {
envelope = JSON.parse(payload);
} catch {
throw new Error('Invalid secret payload');
}
if (
envelope?.v !== SECRET_VERSION ||
envelope.cipher !== SECRET_CIPHER ||
envelope.kdf !== SECRET_KDF ||
!Number.isInteger(envelope.iterations) ||
envelope.iterations < 100_000 ||
envelope.iterations > 5_000_000
) {
throw new Error('Unsupported secret payload');
}
return envelope;
}
export function readSecretTitle(payload: string): string {
try {
const parsed = JSON.parse(payload);
return typeof parsed?.title === 'string' && parsed.title.trim()
? parsed.title.trim()
: 'Encrypted secret';
} catch {
return 'Encrypted secret';
}
}
async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
if (!passphrase) throw new Error('Passphrase is required');
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise<string> {
if (!plaintext) throw new Error('Secret text is required');
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS);
const envelope: SecretEnvelope = {
v: SECRET_VERSION,
title: title.trim() || 'Encrypted secret',
cipher: SECRET_CIPHER,
kdf: SECRET_KDF,
iterations: SECRET_ITERATIONS,
salt: bytesToBase64(salt),
nonce: bytesToBase64(nonce),
ct: '',
};
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
encoder.encode(plaintext),
);
envelope.ct = bytesToBase64(new Uint8Array(encrypted));
return JSON.stringify(envelope, null, 2);
}
export async function decryptSecretText(payload: string, passphrase: string): Promise<string> {
try {
const envelope = parseEnvelope(payload);
const salt = base64ToBytes(envelope.salt, 'salt');
const nonce = base64ToBytes(envelope.nonce, 'nonce');
const ciphertext = base64ToBytes(envelope.ct, 'ciphertext');
const key = await deriveKey(passphrase, salt, envelope.iterations);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
stableBytes(ciphertext),
);
return decoder.decode(plaintext);
} catch {
throw new Error('Unable to unlock secret. Check the passphrase or payload.');
}
}
+18 -7
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
import { get } from 'svelte/store';
import { darkThemes } from '$lib/platform';
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
let { children } = $props();
@@ -15,9 +16,15 @@
applyTheme($theme);
});
// Apply link arrow visibility from config
$effect(() => {
if ($appConfig) {
document.documentElement.classList.toggle('no-link-arrows', !$appConfig.show_link_arrows);
}
});
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
@@ -53,7 +60,7 @@
}
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
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));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
@@ -88,12 +95,16 @@
// Detect install type and check for updates on startup
onMount(() => {
// Authoritative platform from the backend (compile-time), overriding the UA guess. (#63)
isMobilePlatform().then((m) => platformIsMobile.set(m)).catch(() => {});
if (isMobile) {
installType.set('android');
checkForUpdateMobile();
} else {
getInstallType().then(t => installType.set(t)).catch(() => {});
checkForUpdate();
getInstallType().then(t => {
installType.set(t);
if (!isManagedInstall(t)) checkForUpdate();
}).catch(() => {});
}
});
+121 -8
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault } from '$lib/api';
import { getAppConfig, openVault, setFontSize } from '$lib/api';
import { darkThemes } from '$lib/platform';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte';
@@ -9,6 +10,100 @@
let loading = $state(true);
let noteWindowPath = $state<string | null>(null);
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null;
const defaultEditorFontSize = 14;
const minEditorFontSize = 10;
const maxEditorFontSize = 32;
function getEditorScrollSurface(): HTMLElement | HTMLTextAreaElement | null {
const sourceEditor = document.querySelector<HTMLTextAreaElement>('.source-editor');
if (sourceEditor && getComputedStyle(sourceEditor).display !== 'none') return sourceEditor;
return document.querySelector<HTMLElement>('.editor-body');
}
function preserveEditorViewportAfterLayout(scrollSurface: HTMLElement | HTMLTextAreaElement | null) {
if (!scrollSurface) return () => {};
const isSourceEditor = scrollSurface instanceof HTMLTextAreaElement;
const maxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
const scrollRatio = maxScroll > 0 ? scrollSurface.scrollTop / maxScroll : 0;
const rect = scrollSurface.getBoundingClientRect();
const anchorY = Math.min(rect.bottom - 1, rect.top + Math.max(16, rect.height * 0.25));
let anchorElement: HTMLElement | null = null;
let anchorOffset = 0;
if (!isSourceEditor) {
const candidate = document.elementFromPoint(rect.left + Math.min(80, rect.width / 2), anchorY);
anchorElement = candidate?.closest('.tiptap *') as HTMLElement | null;
if (anchorElement) anchorOffset = anchorElement.getBoundingClientRect().top - rect.top;
}
return () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (anchorElement && scrollSurface.contains(anchorElement)) {
const nextRect = scrollSurface.getBoundingClientRect();
scrollSurface.scrollTop += anchorElement.getBoundingClientRect().top - nextRect.top - anchorOffset;
} else {
const nextMaxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
scrollSurface.scrollTop = Math.max(0, nextMaxScroll * scrollRatio);
}
});
});
};
}
function setEditorFontSize(value: number) {
const nextSize = Math.max(minEditorFontSize, Math.min(maxEditorFontSize, Math.round(value)));
const restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface());
if ($appConfig) $appConfig.font_size = nextSize;
document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`);
restoreEditorViewport();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
fontSizeSaveTimer = setTimeout(() => {
setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e));
fontSizeSaveTimer = null;
}, 150);
}
function installEditorZoomShortcuts() {
const zoomEditor = (delta: number) => setEditorFontSize(($appConfig?.font_size ?? defaultEditorFontSize) + delta);
const handleKeydown = (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
if (event.altKey || event.shiftKey) return;
if (event.key === '+' || event.key === '=') {
event.preventDefault();
event.stopPropagation();
zoomEditor(1);
} else if (event.key === '-' || event.key === '_') {
event.preventDefault();
event.stopPropagation();
zoomEditor(-1);
} else if (event.key === '0') {
event.preventDefault();
event.stopPropagation();
setEditorFontSize(defaultEditorFontSize);
}
};
const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
event.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1);
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('wheel', handleWheel, { capture: true });
};
}
onMount(async () => {
// Check for note window mode
@@ -26,8 +121,7 @@
// Apply theme immediately to prevent flash
const themeValue = config.theme || 'system';
const root = document.documentElement;
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (namedThemes.includes(themeValue)) {
@@ -44,6 +138,9 @@
if (config.line_height) {
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
}
if (config.content_width) {
document.documentElement.style.setProperty('--editor-content-width', `${config.content_width}px`);
}
if (config.font_family) {
const fontStacks: Record<string, string> = {
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
@@ -60,6 +157,12 @@
}
// Apply saved accent
if (config.accent_color) {
const themeVal = config.theme || 'light';
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
let color: string | null = null;
if (config.accent_color.startsWith('#')) {
color = config.accent_color;
} else {
const accentPresets: Record<string, { light: string; dark: string }> = {
Indigo: { light: '#5b6abf', dark: '#7b9bd4' },
Rose: { light: '#e11d48', dark: '#d4768a' },
@@ -69,12 +172,16 @@
Cyan: { light: '#0891b2', dark: '#5ba8b5' },
Orange: { light: '#ea580c', dark: '#c98560' },
Teal: { light: '#0d9488', dark: '#5fada5' },
Sakura: { light: '#d4547a', dark: '#f4a0b5' },
Gold: { light: '#b8860b', dark: '#fabd2f' },
Lavender: { light: '#9d5cdb', dark: '#c084fc' },
Sky: { light: '#0284c7', dark: '#38bdf8' },
Coral: { light: '#e85d4a', dark: '#ff8a7a' },
};
const preset = accentPresets[config.accent_color];
if (preset) {
const themeVal = config.theme || 'light';
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const color = isDark ? preset.dark : preset.light;
if (preset) color = isDark ? preset.dark : preset.light;
}
if (color) {
const root = document.documentElement;
root.style.setProperty('--accent', color);
root.style.setProperty('--text-accent', color);
@@ -93,6 +200,7 @@
console.error('Failed to apply interface scale:', e);
}
}
if (!noteWindowPath) removeEditorZoomShortcuts = installEditorZoomShortcuts();
// Auto-open last vault if available
if (config.active_vault) {
@@ -111,6 +219,11 @@
}
loading = false;
});
onDestroy(() => {
removeEditorZoomShortcuts?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
});
</script>
{#if loading}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata) Literata-Italic[opsz,wght].ttf: Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". Lora-Italic[wght].ttf: Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather". Merriweather-Italic[opsz,wdth,wght].ttf: Copyright 2024 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) OpenSans-Italic[wdth,wght].ttf: Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.