Compare 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
29 changed files with 1594 additions and 254 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
# HelixNotes # 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) [![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.3.1-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) [![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)]() [![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. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.3.1) ## Download (v1.3.2)
### Linux ### Linux
@@ -92,23 +92,23 @@ sudo eopkg it helixnotes
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.1/HelixNotes_1.3.1_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_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 ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_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 ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_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 ### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_android.apk) [Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
--- ---
+1 -1
View File
@@ -22,7 +22,7 @@
inherit pname version; inherit pname version;
src = ./.; src = ./.;
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk="; cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
pnpmDeps = pkgs.fetchPnpmDeps { pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version; inherit pname version;
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.3.1", "version": "1.3.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -59,6 +59,7 @@
"@tiptap/starter-kit": "^3.19.0", "@tiptap/starter-kit": "^3.19.0",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
+3
View File
@@ -95,6 +95,9 @@ importers:
'@types/markdown-it': '@types/markdown-it':
specifier: ^14.1.2 specifier: ^14.1.2
version: 14.1.2 version: 14.1.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
katex: katex:
specifier: ^0.16.28 specifier: ^0.16.28
version: 0.16.28 version: 0.16.28
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.3.1" version = "1.3.2"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.3.1" version = "1.3.2"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
+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

+20
View File
@@ -122,6 +122,14 @@ pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String
Ok(()) 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 ── // ── Notebooks ──
#[tauri::command] #[tauri::command]
@@ -1087,17 +1095,28 @@ pub fn set_general_settings(
title_mode: String, title_mode: String,
hide_title_in_body: bool, hide_title_in_body: bool,
show_line_numbers: bool, show_line_numbers: bool,
show_link_arrows: bool,
default_view_mode: bool, default_view_mode: bool,
show_tray_icon: bool, show_tray_icon: bool,
close_to_tray: bool, close_to_tray: bool,
enable_wiki_links: bool, enable_wiki_links: bool,
show_note_dates: bool, show_note_dates: bool,
restore_last_session: 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> { ) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes; config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates; config.show_note_dates = show_note_dates;
config.restore_last_session = restore_last_session; 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.time_format = time_format;
config.week_start = week_start; config.week_start = week_start;
config.daily_title_format = daily_title_format; config.daily_title_format = daily_title_format;
@@ -1108,6 +1127,7 @@ pub fn set_general_settings(
config.title_mode = title_mode; config.title_mode = title_mode;
config.hide_title_in_body = hide_title_in_body; config.hide_title_in_body = hide_title_in_body;
config.show_line_numbers = show_line_numbers; config.show_line_numbers = show_line_numbers;
config.show_link_arrows = show_link_arrows;
config.default_view_mode = default_view_mode; config.default_view_mode = default_view_mode;
config.show_tray_icon = show_tray_icon; config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray; config.close_to_tray = close_to_tray;
+1
View File
@@ -106,6 +106,7 @@ pub fn run() {
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
commands::set_ui_scale, commands::set_ui_scale,
commands::set_content_width,
commands::get_notebooks, commands::get_notebooks,
commands::count_root_notes, commands::count_root_notes,
commands::create_notebook, commands::create_notebook,
+26 -11
View File
@@ -339,14 +339,29 @@ impl WebdavClient {
Ok(()) Ok(())
} }
fn put_with_dirs( /// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
/// to keep it consistent with future listings. (issue #147)
fn etag_for(&self, relpath: &str) -> String {
self.propfind(relpath, "0")
.ok()
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
.unwrap_or_default()
}
/// PUT a file (creating parent collections first), then read back its authoritative
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
fn upload(
&self, &self,
relpath: &str, relpath: &str,
bytes: &[u8], bytes: &[u8],
created: &mut HashSet<String>, created: &mut HashSet<String>,
) -> Result<Option<String>, String> { ) -> Result<String, String> {
self.ensure_dirs(relpath, created)?; self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes) self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
Ok(self.etag_for(relpath))
} }
} }
@@ -511,12 +526,12 @@ fn apply_changes(
); );
} else if local_changed && !remote_changed { } else if local_changed && !remote_changed {
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?; let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?; let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert( new_m.files.insert(
key.clone(), key.clone(),
ManifestEntry { ManifestEntry {
local_hash: lf.hash.clone(), local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()), remote_etag: etag,
}, },
); );
sum.uploaded += 1; sum.uploaded += 1;
@@ -556,12 +571,12 @@ fn apply_changes(
); );
let ckey = conflict_relpath(key, &stamp); let ckey = conflict_relpath(key, &stamp);
write_local(vault_path, &ckey, &lbytes)?; write_local(vault_path, &ckey, &lbytes)?;
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?; let cetag = client.upload(&ckey, &lbytes, &mut created)?;
new_m.files.insert( new_m.files.insert(
ckey, ckey,
ManifestEntry { ManifestEntry {
local_hash: sha256_hex(&lbytes), local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(), remote_etag: cetag,
}, },
); );
sum.conflicts += 1; sum.conflicts += 1;
@@ -572,12 +587,12 @@ fn apply_changes(
None => { None => {
// new local -> upload // new local -> upload
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?; let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?; let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert( new_m.files.insert(
key.clone(), key.clone(),
ManifestEntry { ManifestEntry {
local_hash: lf.hash.clone(), local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(), remote_etag: etag,
}, },
); );
sum.uploaded += 1; sum.uploaded += 1;
@@ -586,12 +601,12 @@ fn apply_changes(
if me.local_hash != lf.hash { if me.local_hash != lf.hash {
// remote deleted but local edited -> resurrect (upload) // remote deleted but local edited -> resurrect (upload)
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?; let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?; let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert( new_m.files.insert(
key.clone(), key.clone(),
ManifestEntry { ManifestEntry {
local_hash: lf.hash.clone(), local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(), remote_etag: etag,
}, },
); );
sum.uploaded += 1; sum.uploaded += 1;
+27
View File
@@ -72,6 +72,8 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub ui_scale: Option<f64>, pub ui_scale: Option<f64>,
#[serde(default)] #[serde(default)]
pub content_width: Option<u32>,
#[serde(default)]
pub compact_notes: bool, pub compact_notes: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub show_note_dates: bool, pub show_note_dates: bool,
@@ -95,6 +97,18 @@ pub struct AppConfig {
pub hide_title_in_body: bool, pub hide_title_in_body: bool,
#[serde(default)] #[serde(default)]
pub show_line_numbers: bool, pub show_line_numbers: bool,
#[serde(default = "default_true")]
pub show_link_arrows: bool,
#[serde(default = "default_true")]
pub show_all_notes: bool,
#[serde(default = "default_true")]
pub show_quick_access: bool,
#[serde(default = "default_true")]
pub show_tasks: bool,
#[serde(default = "default_true")]
pub show_daily_notes: bool,
#[serde(default = "default_true")]
pub show_trash: bool,
#[serde(default)] #[serde(default)]
pub backup_enabled: bool, pub backup_enabled: bool,
#[serde(default = "default_backup_frequency")] #[serde(default = "default_backup_frequency")]
@@ -203,6 +217,7 @@ impl Default for AppConfig {
font_family: None, font_family: None,
line_height: None, line_height: None,
ui_scale: None, ui_scale: None,
content_width: None,
compact_notes: false, compact_notes: false,
show_note_dates: true, show_note_dates: true,
time_format: "relative".to_string(), time_format: "relative".to_string(),
@@ -215,6 +230,12 @@ impl Default for AppConfig {
title_mode: "input".to_string(), title_mode: "input".to_string(),
hide_title_in_body: false, hide_title_in_body: false,
show_line_numbers: false, show_line_numbers: false,
show_link_arrows: true,
show_all_notes: true,
show_quick_access: true,
show_tasks: true,
show_daily_notes: true,
show_trash: true,
backup_enabled: false, backup_enabled: false,
backup_frequency: "24h".to_string(), backup_frequency: "24h".to_string(),
backup_max_count: 10, backup_max_count: 10,
@@ -255,12 +276,16 @@ pub struct VaultState {
pub notelist_width: f64, pub notelist_width: f64,
pub sidebar_collapsed: bool, pub sidebar_collapsed: bool,
#[serde(default)] #[serde(default)]
pub notelist_collapsed: bool,
#[serde(default)]
pub collapsed_notebooks: Vec<String>, pub collapsed_notebooks: Vec<String>,
#[serde(default)] #[serde(default)]
pub notebook_sort_mode: String, pub notebook_sort_mode: String,
#[serde(default)] #[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>, pub notebook_order: std::collections::HashMap<String, i32>,
#[serde(default)] #[serde(default)]
pub note_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub sort_mode: String, pub sort_mode: String,
#[serde(default)] #[serde(default)]
pub last_view_mode: String, pub last_view_mode: String,
@@ -285,9 +310,11 @@ impl Default for VaultState {
sidebar_width: 220.0, sidebar_width: 220.0,
notelist_width: 280.0, notelist_width: 280.0,
sidebar_collapsed: false, sidebar_collapsed: false,
notelist_collapsed: false,
collapsed_notebooks: Vec::new(), collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(), notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(), notebook_order: std::collections::HashMap::new(),
note_order: std::collections::HashMap::new(),
sort_mode: String::new(), sort_mode: String::new(),
last_view_mode: String::new(), last_view_mode: String::new(),
last_notebook: None, last_notebook: None,
+20 -1
View File
@@ -386,7 +386,26 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (meta, content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Fall back to filesystem times when the frontmatter has no created/modified, matching the
// note-list scan (read_note_entry_from_str). Without this, a missing `created:` defaults to
// Utc::now() here and then gets written to disk on the next save (e.g. a title change),
// resetting the create date of imported/frontmatter-less notes. (issue #139)
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
if let Ok(fs_meta) = fs::metadata(p) {
if !has_modified {
if let Ok(modified) = fs_meta.modified() {
meta.modified = modified.into();
}
}
if !has_created {
if let Ok(created) = fs_meta.created() {
meta.created = created.into();
}
}
}
Ok(NoteContent { Ok(NoteContent {
path: path.to_string(), path: path.to_string(),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.3.1", "version": "1.3.2",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+25
View File
@@ -961,6 +961,31 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.8); --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, *::before,
*::after { *::after {
+16
View File
@@ -52,6 +52,10 @@ export async function setUiScale(scale: number): Promise<void> {
return invoke("set_ui_scale", { scale }); return invoke("set_ui_scale", { scale });
} }
export async function setContentWidth(width: number | null): Promise<void> {
return invoke("set_content_width", { width });
}
export async function getNotebooks(): Promise<NotebookEntry[]> { export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks"); return invoke("get_notebooks");
} }
@@ -228,12 +232,18 @@ export async function setGeneralSettings(
titleMode: string, titleMode: string,
hideTitleInBody: boolean, hideTitleInBody: boolean,
showLineNumbers: boolean, showLineNumbers: boolean,
showLinkArrows: boolean,
defaultViewMode: boolean, defaultViewMode: boolean,
showTrayIcon: boolean, showTrayIcon: boolean,
closeToTray: boolean, closeToTray: boolean,
enableWikiLinks: boolean, enableWikiLinks: boolean,
showNoteDates: boolean, showNoteDates: boolean,
restoreLastSession: boolean, restoreLastSession: boolean,
showAllNotes: boolean,
showQuickAccess: boolean,
showTasks: boolean,
showDailyNotes: boolean,
showTrash: boolean,
): Promise<void> { ): Promise<void> {
return invoke("set_general_settings", { return invoke("set_general_settings", {
compactNotes, compactNotes,
@@ -247,12 +257,18 @@ export async function setGeneralSettings(
titleMode, titleMode,
hideTitleInBody, hideTitleInBody,
showLineNumbers, showLineNumbers,
showLinkArrows,
defaultViewMode, defaultViewMode,
showTrayIcon, showTrayIcon,
closeToTray, closeToTray,
enableWikiLinks, enableWikiLinks,
showNoteDates, showNoteDates,
restoreLastSession, restoreLastSession,
showAllNotes,
showQuickAccess,
showTasks,
showDailyNotes,
showTrash,
}); });
} }
+127 -48
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import Sidebar from './Sidebar.svelte'; import Sidebar from './Sidebar.svelte';
import NoteList from './NoteList.svelte'; import NoteList from './NoteList.svelte';
@@ -15,6 +15,7 @@
sidebarWidth, sidebarWidth,
notelistWidth, notelistWidth,
sidebarCollapsed, sidebarCollapsed,
notelistCollapsed,
collapsedNotebooks, collapsedNotebooks,
showSearch, showSearch,
showCommandPalette, showCommandPalette,
@@ -48,15 +49,18 @@
viewerNote, viewerNote,
notebookSortMode, notebookSortMode,
notebookOrder, notebookOrder,
noteOrder,
syncState, syncState,
platformIsMobile platformIsMobile
} from '$lib/stores/app'; } from '$lib/stores/app';
import { keybindings, matchAction } from '$lib/keybindings';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile); const isMobile = $derived($platformIsMobile);
const isAndroid = /android/i.test(navigator.userAgent); 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 { 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 { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@@ -199,9 +203,11 @@
sidebar_width: $sidebarWidth, sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode, notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder, notebook_order: $notebookOrder,
note_order: $noteOrder,
sort_mode: $sortMode, sort_mode: $sortMode,
last_view_mode: $viewMode, last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null, last_notebook: $activeNotebook?.relative_path ?? null,
@@ -226,6 +232,19 @@
persistState(); persistState();
} }
function revealNoteList() {
$notelistCollapsed = false;
tick().then(() => noteList?.refresh());
}
function toggleNoteList() {
const nextCollapsed = !$notelistCollapsed;
$notelistCollapsed = nextCollapsed;
if (!nextCollapsed) {
tick().then(() => noteList?.refresh());
}
}
// Tasks view: the editor pane shows a placeholder until a task is opened from the list. // Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false); let taskNoteOpened = $state(false);
@@ -239,7 +258,14 @@
function handleViewChanged() { function handleViewChanged() {
taskNoteOpened = false; 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(); noteList?.refresh();
}
if (isMobile) $mobileView = 'notelist'; if (isMobile) $mobileView = 'notelist';
} }
@@ -360,6 +386,16 @@
navFromPopstate = false; navFromPopstate = false;
return; return;
} }
// If a modal is open, close it instead of navigating
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
$showSettings = false;
$showInfo = false;
$showSearch = false;
$showCommandPalette = false;
// Re-push the current state so the next back still works
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
return;
}
const state = e.state; const state = e.state;
const targetDepth = state?.depth ?? 0; const targetDepth = state?.depth ?? 0;
historyDepth = targetDepth; historyDepth = targetDepth;
@@ -383,88 +419,76 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const code = e.code; const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') {
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault(); e.preventDefault();
editor?.addLinkFromToolbar();
}
const action = matchAction(e, $keybindings);
if (action) {
e.preventDefault();
switch (action) {
case 'nav-back':
navigateHistory(-1); navigateHistory(-1);
return; return;
} case 'nav-forward':
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
navigateHistory(1); navigateHistory(1);
return; return;
} case 'new-note':
if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault();
createAndFocusNote(); createAndFocusNote();
return; return;
} case 'toggle-theme': {
if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark'; const next = isDark ? 'light' : 'dark';
$theme = next; $theme = next;
setTheme(next); setTheme(next);
return; return;
} }
if (mod && e.shiftKey && code === 'KeyF') { case 'search-vault':
e.preventDefault();
$showSearch = true; $showSearch = true;
return; return;
} case 'find-in-note':
if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault();
if ($activeNotePath) { if ($activeNotePath) {
editor?.openNoteSearch(); editor?.openNoteSearch();
} else { } else {
$showSearch = true; $showSearch = true;
} }
return; return;
} case 'quick-open':
if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
return; return;
} case 'save':
if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return; return;
} case 'toggle-source':
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} return;
if (mod && e.shiftKey && code === 'KeyW') { case 'open-new-window':
e.preventDefault();
if ($activeNotePath && $activeNote) { if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title); openNoteWindow($activeNotePath, $activeNote.meta.title);
} }
} return;
if (mod && e.key === '\\') { case 'toggle-sidebar':
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed; $sidebarCollapsed = !$sidebarCollapsed;
return; return;
} case 'toggle-notelist':
if (mod && e.shiftKey && code === 'KeyE') { toggleNoteList();
e.preventDefault(); return;
case 'toggle-focus':
$focusMode = !$focusMode; $focusMode = !$focusMode;
return; return;
} case 'toggle-readonly':
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
if ($viewerNote) return; // viewer mode is always read-only if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly; $readOnly = !$readOnly;
return; return;
} case 'fullscreen':
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs)); appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return; return;
} }
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
@@ -475,8 +499,7 @@
} }
function applyTheme(t: string) { function applyTheme(t: string) {
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']; const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); root.classList.remove('dark');
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
@@ -502,12 +525,23 @@
persistState(); persistState();
}); });
$effect(() => {
$sidebarCollapsed;
$notelistCollapsed;
persistState();
});
$effect(() => { $effect(() => {
$notebookSortMode; $notebookSortMode;
$notebookOrder; $notebookOrder;
persistState(); persistState();
}); });
$effect(() => {
$noteOrder;
persistState();
});
$effect(() => { $effect(() => {
$sortMode; $sortMode;
$tasksLayout; $tasksLayout;
@@ -534,10 +568,12 @@
$sidebarWidth = state.sidebar_width; $sidebarWidth = state.sidebar_width;
$notelistWidth = state.notelist_width; $notelistWidth = state.notelist_width;
$sidebarCollapsed = state.sidebar_collapsed; $sidebarCollapsed = state.sidebar_collapsed;
$notelistCollapsed = state.notelist_collapsed ?? false;
$collapsedNotebooks = state.collapsed_notebooks ?? []; $collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical'; $notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {}; $notebookOrder = state.notebook_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode; $noteOrder = state.note_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout; if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed; if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged; if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
@@ -895,11 +931,24 @@
<ResizeHandle onResize={handleSidebarResize} /> <ResizeHandle onResize={handleSidebarResize} />
{/if} {/if}
{#if !$notelistCollapsed}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
{:else}
<div class="notelist-restore-panel">
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="20" y2="6" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="18" x2="20" y2="18" />
<polyline points="5 8 3 12 5 16" />
</svg>
</button>
</div>
{/if}
{/if} {/if}
<div class="editor-panel"> <div class="editor-panel">
@@ -918,7 +967,7 @@
{/if} {/if}
<SearchPanel /> <SearchPanel />
<CommandPalette /> <CommandPalette onNavigate={handleViewChanged} />
<SettingsPanel /> <SettingsPanel />
<InfoPanel /> <InfoPanel />
@@ -949,6 +998,36 @@
overflow: hidden; overflow: hidden;
} }
.notelist-restore-panel {
flex-shrink: 0;
width: 36px;
height: 100%;
padding-top: 8px;
display: flex;
justify-content: center;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
}
.notelist-restore-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.notelist-restore-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.editor-panel { .editor-panel {
flex: 1; flex: 1;
height: 100%; height: 100%;
+40 -3
View File
@@ -1,6 +1,19 @@
<script lang="ts"> <script lang="ts">
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app'; import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
import { setTheme, reindex } from '$lib/api'; import { setTheme, reindex } from '$lib/api';
import { darkThemes } from '$lib/platform';
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
$viewMode = mode;
$activeNotebook = null;
$activeTag = null;
$showCommandPalette = false;
onNavigate();
}
interface Command { interface Command {
id: string; id: string;
@@ -25,6 +38,31 @@
$showSearch = true; $showSearch = true;
} }
}, },
{
id: 'open-all-notes',
label: 'Open All Notes',
action: () => openView('all')
},
{
id: 'open-quick-access',
label: 'Open Quick Access',
action: () => openView('quickaccess')
},
{
id: 'open-tasks',
label: 'Open Tasks',
action: () => openView('tasks')
},
{
id: 'open-daily',
label: 'Open Daily Notes',
action: () => openView('daily')
},
{
id: 'open-trash',
label: 'Open Trash (restore deleted notes)',
action: () => openView('trash')
},
{ {
id: 'theme-light', id: 'theme-light',
label: 'Switch to Light Theme', label: 'Switch to Light Theme',
@@ -142,8 +180,7 @@
} }
function applyTheme(t: string) { function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson']; const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); root.classList.remove('dark');
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
+405 -63
View File
@@ -23,6 +23,7 @@
import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details'; import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details';
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import { common, createLowlight } from 'lowlight'; import { common, createLowlight } from 'lowlight';
import powershell from 'highlight.js/lib/languages/powershell';
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import markdownItMark from 'markdown-it-mark'; import markdownItMark from 'markdown-it-mark';
import markdownItSup from 'markdown-it-sup'; import markdownItSup from 'markdown-it-sup';
@@ -276,8 +277,8 @@
if (!q || q.startsWith('http://') || q.startsWith('https://') || q.startsWith('mailto:')) return []; if (!q || q.startsWith('http://') || q.startsWith('https://') || q.startsWith('mailto:')) return [];
return linkSuggestTitles.filter(e => e.title.toLowerCase().includes(q)).slice(0, 8); return linkSuggestTitles.filter(e => e.title.toLowerCase().includes(q)).slice(0, 8);
}); });
let textContextMenu = $state<{ x: number; y: number } | null>(null); let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
let tableContextMenu = $state<{ x: number; y: number } | null>(null); let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
let tablePickerOpen = $state(false); let tablePickerOpen = $state(false);
let tablePickerHover = $state({ rows: 0, cols: 0 }); let tablePickerHover = $state({ rows: 0, cols: 0 });
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null); let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
@@ -498,6 +499,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register('powershell', powershell);
const codeLanguages = [...lowlight.listLanguages(), 'mermaid'].sort(); const codeLanguages = [...lowlight.listLanguages(), 'mermaid'].sort();
const mdit = MarkdownIt({ html: true, linkify: false, breaks: false }) const mdit = MarkdownIt({ html: true, linkify: false, breaks: false })
.use(markdownItMark) .use(markdownItMark)
@@ -1211,10 +1213,20 @@
}); });
let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null); let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null);
let codeLangSearch = $state('');
let codeLangInput = $state<HTMLInputElement | null>(null);
let codeLangFiltered = $derived.by(() => {
if (!codeLangSearch) return codeLanguages;
const q = codeLangSearch.toLowerCase();
return codeLanguages.filter(l => l.includes(q));
});
function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) { function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) {
const rect = triggerEl.getBoundingClientRect(); const rect = triggerEl.getBoundingClientRect();
codeLangSearch = '';
codeLangDropdown = { pos, x: rect.right, y: rect.bottom + 4, current }; codeLangDropdown = { pos, x: rect.right, y: rect.bottom + 4, current };
tick().then(() => codeLangInput?.focus());
} }
function selectCodeLang(lang: string) { function selectCodeLang(lang: string) {
@@ -1325,6 +1337,45 @@
event.preventDefault(); event.preventDefault();
const { state, dispatch } = view; const { state, dispatch } = view;
const resolvedPos = state.selection.$from; const resolvedPos = state.selection.$from;
if (event.shiftKey) {
for (let depth = resolvedPos.depth; depth > 0; depth--) {
const itemNode = resolvedPos.node(depth);
if (itemNode.type.name !== 'listItem' && itemNode.type.name !== 'taskItem') continue;
const parentListDepth = depth - 1;
const itemIndex = resolvedPos.index(parentListDepth);
const itemPos = resolvedPos.before(depth);
const itemSlice = state.doc.slice(itemPos, itemPos + itemNode.nodeSize);
const cursorOffset = resolvedPos.pos - itemPos;
const tr = state.tr;
if (event.key === 'ArrowUp') {
if (itemIndex <= 0) return true;
const prevPos = resolvedPos.posAtIndex(itemIndex - 1, parentListDepth);
tr.delete(itemPos, itemPos + itemNode.nodeSize);
const insertAt = tr.mapping.map(prevPos);
tr.insert(insertAt, itemSlice.content);
const newCursorPos = Math.min(insertAt + cursorOffset, tr.doc.content.size);
tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
dispatch(tr.scrollIntoView());
return true;
}
const parentList = resolvedPos.node(parentListDepth);
if (itemIndex >= parentList.childCount - 1) return true;
const nextPos = resolvedPos.posAtIndex(itemIndex + 1, parentListDepth);
const nextNode = state.doc.nodeAt(nextPos);
if (!nextNode) return true;
const nextSlice = state.doc.slice(nextPos, nextPos + nextNode.nodeSize);
tr.delete(nextPos, nextPos + nextNode.nodeSize);
const insertAt = tr.mapping.map(itemPos);
tr.insert(insertAt, nextSlice.content);
const newCursorPos = Math.min(tr.mapping.map(itemPos) + cursorOffset, tr.doc.content.size);
tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
dispatch(tr.scrollIntoView());
return true;
}
return true;
}
// Find the top-level block index // Find the top-level block index
const depth = 1; // top-level blocks in doc const depth = 1; // top-level blocks in doc
if (resolvedPos.depth < depth) return true; if (resolvedPos.depth < depth) return true;
@@ -1345,7 +1396,7 @@
const insertAt = tr.mapping.map(prevPos); const insertAt = tr.mapping.map(prevPos);
tr.insert(insertAt, curSlice.content); tr.insert(insertAt, curSlice.content);
const newCursorPos = Math.min(insertAt + cursorOffset, tr.doc.content.size); const newCursorPos = Math.min(insertAt + cursorOffset, tr.doc.content.size);
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos))); tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
dispatch(tr.scrollIntoView()); dispatch(tr.scrollIntoView());
} else { } else {
if (parentIndex >= state.doc.childCount - 1) return true; if (parentIndex >= state.doc.childCount - 1) return true;
@@ -1360,7 +1411,7 @@
const insertAt = tr.mapping.map(parentPos); const insertAt = tr.mapping.map(parentPos);
tr.insert(insertAt, nextSlice.content); tr.insert(insertAt, nextSlice.content);
const newCursorPos = Math.min(tr.mapping.map(parentPos) + cursorOffset, tr.doc.content.size); const newCursorPos = Math.min(tr.mapping.map(parentPos) + cursorOffset, tr.doc.content.size);
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos))); tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
dispatch(tr.scrollIntoView()); dispatch(tr.scrollIntoView());
} }
return true; return true;
@@ -1422,7 +1473,7 @@
if (!pre) return false; if (!pre) return false;
// Check if click is in the top-right corner (language button area) // Check if click is in the top-right corner (language button area)
const rect = pre.getBoundingClientRect(); const rect = pre.getBoundingClientRect();
if (event.clientX < rect.right - 70 || event.clientY > rect.top + 30) return false; if (event.clientX < rect.right - 100 || event.clientY > rect.top + 30) return false;
// Find the code block position // Find the code block position
const pos = view.posAtDOM(pre, 0); const pos = view.posAtDOM(pre, 0);
const resolved = view.state.doc.resolve(pos); const resolved = view.state.doc.resolve(pos);
@@ -1443,10 +1494,10 @@
// Virtual trigger for dropdown positioning // Virtual trigger for dropdown positioning
const triggerEl = document.createElement('div'); const triggerEl = document.createElement('div');
triggerEl.getBoundingClientRect = () => ({ triggerEl.getBoundingClientRect = () => ({
top: rect.top + 6, bottom: rect.top + 26, top: rect.top + 5, bottom: rect.top + 25,
left: rect.right - 60, right: rect.right - 6, left: rect.right - 100, right: rect.right - 34,
width: 54, height: 20, width: 66, height: 20,
x: rect.right - 60, y: rect.top + 6, x: rect.right - 100, y: rect.top + 5,
toJSON() { return this; }, toJSON() { return this; },
}); });
openCodeLangDropdown(cbPos + 1, cbNode.attrs.language || '', triggerEl as any); openCodeLangDropdown(cbPos + 1, cbNode.attrs.language || '', triggerEl as any);
@@ -2620,6 +2671,61 @@
return result.replace(/\n{3,}/g, '\n\n').trim() + '\n'; return result.replace(/\n{3,}/g, '\n\n').trim() + '\n';
} }
function tableToMarkdown(table: any): string {
const rows: string[][] = [];
let hasHeader = false;
table.forEach((row: any) => {
if (row.type.name !== 'tableRow') return;
const cells: string[] = [];
row.forEach((cell: any) => {
if (cell.type.name === 'tableHeader') hasHeader = true;
const cellText: string[] = [];
cell.forEach((p: any) => {
cellText.push(serializeInline(p));
});
cells.push(cellText.join(' ').replace(/\|/g, '\\|').replace(/\n/g, ' '));
});
rows.push(cells);
});
if (rows.length === 0) return '';
const colCount = Math.max(...rows.map(r => r.length));
const lines: string[] = [];
rows.forEach((row, i) => {
lines.push('| ' + row.join(' | ') + ' |');
if (i === 0 && hasHeader) {
lines.push('| ' + Array(colCount).fill('---').join(' | ') + ' |');
}
});
return lines.join('\n');
}
function resetTableToMarkdown() {
if (!editor) return;
const { state } = editor;
let { tr } = state;
let changed = false;
state.doc.descendants((node: any, pos: number) => {
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
if (node.attrs.backgroundColor || (node.attrs.colspan && node.attrs.colspan > 1) || (node.attrs.rowspan && node.attrs.rowspan > 1)) {
tr = tr.setNodeMarkup(pos, undefined, {
...node.attrs,
backgroundColor: null,
colspan: 1,
rowspan: 1,
});
changed = true;
}
}
return true;
});
if (changed) {
editor.view.dispatch(tr);
$editorDirty = true;
autoSave();
}
closeTableContextMenu();
}
function serializeNode(node: any): string { function serializeNode(node: any): string {
switch (node.type.name) { switch (node.type.name) {
case 'paragraph': { case 'paragraph': {
@@ -2676,12 +2782,24 @@
case 'pageBreak': case 'pageBreak':
return '<div style="page-break-after: always;"></div>\n'; return '<div style="page-break-after: always;"></div>\n';
case 'table': { case 'table': {
// Preserve tables as raw HTML // Check if any cell has styling (background color) or if there are merged cells
let hasStyling = false;
node.descendants((child: any) => {
if (child.type.name === 'tableCell' || child.type.name === 'tableHeader') {
if (child.attrs.backgroundColor) hasStyling = true;
if (child.attrs.colspan && child.attrs.colspan > 1) hasStyling = true;
if (child.attrs.rowspan && child.attrs.rowspan > 1) hasStyling = true;
}
return true;
});
if (hasStyling) {
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node); const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
tempDiv.appendChild(frag); tempDiv.appendChild(frag);
return tempDiv.innerHTML + '\n'; return tempDiv.innerHTML + '\n';
} }
return tableToMarkdown(node) + '\n';
}
case 'pdfEmbed': { case 'pdfEmbed': {
const src = node.attrs.src || ''; const src = node.attrs.src || '';
const name = node.attrs.name || ''; const name = node.attrs.name || '';
@@ -2863,11 +2981,63 @@
// Only steal focus when navigating (Enter/Shift+Enter), not while typing // Only steal focus when navigating (Enter/Shift+Enter), not while typing
if (focusTextarea) sourceElement.focus(); if (focusTextarea) sourceElement.focus();
sourceElement.setSelectionRange(match.from, match.to); sourceElement.setSelectionRange(match.from, match.to);
// Scroll the match into view scrollSourceToOffset(match.from);
const linesBefore = sourceContent.substring(0, match.from).split('\n').length; }
// Centre the source textarea's viewport on the line containing `offset`.
function scrollSourceToOffset(offset: number) {
if (!sourceElement) return;
const linesBefore = sourceContent.substring(0, offset).split('\n').length;
const lineHeight = parseFloat(getComputedStyle(sourceElement).lineHeight) || 20; const lineHeight = parseFloat(getComputedStyle(sourceElement).lineHeight) || 20;
const targetScroll = (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2; sourceElement.scrollTop = Math.max(0, (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2);
sourceElement.scrollTop = Math.max(0, targetScroll); }
// ── Caret carry-over between the rich editor and the markdown source view (issue #125) ──
// The caret is a ProseMirror doc position in one mode and a character offset in the other,
// and the markdown<->doc conversion is lossy, so rather than invert the conversion we anchor
// on VISIBLE TEXT. `scanAlign` walks the markdown source against the doc's plain text (its
// non-whitespace characters); markup characters don't match the next expected visible char and
// are skipped, so the scan locks onto real words and self-resynchronises. This stays correct
// even at the end of a long note and is decoupled from how serialization happens to format.
// - stopAtNw >= 0: stop once that many visible chars have matched -> srcOffset is the source
// position just past them (maps a doc caret to a source offset).
// - limit >= 0: stop at source index `limit` -> nwCount is how many visible chars precede it
// (maps a source caret to a doc visible-char count).
function scanAlign(source: string, docNonWs: string, opts: { limit?: number; stopAtNw?: number }): { srcOffset: number; nwCount: number } {
const limit = opts.limit ?? -1;
const stopAtNw = opts.stopAtNw ?? -1;
if (stopAtNw === 0) return { srcOffset: 0, nwCount: 0 };
let k = 0;
let i = 0;
for (; i < source.length; i++) {
if (limit >= 0 && i >= limit) break;
const c = source[i];
if (c === ' ' || c === '\n' || c === '\t' || c === '\r') continue;
if (c === docNonWs[k]) {
k++;
if (stopAtNw >= 0 && k >= stopAtNw) { i++; break; }
}
}
return { srcOffset: i, nwCount: k };
}
// The whole editor doc as plain text with whitespace removed (the alignment alphabet).
function docNonWhitespace(): string {
if (!editor) return '';
return editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n', '').replace(/\s/g, '');
}
// Editor doc position just after the `targetNw`-th non-whitespace character.
function docPosForNonWsCount(doc: any, targetNw: number): number {
if (targetNw <= 0) return 0;
const nwAt = (pos: number) => doc.textBetween(0, pos, '\n', '').replace(/\s/g, '').length;
let lo = 0;
let hi = doc.content.size;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (nwAt(mid) >= targetNw) hi = mid; else lo = mid + 1;
}
return lo;
} }
function resetSourceHistory(content: string) { function resetSourceHistory(content: string) {
@@ -3213,6 +3383,21 @@
// Pre-process: fix image paths with multiple leading slashes (from broken saves) // Pre-process: fix image paths with multiple leading slashes (from broken saves)
src = src.replace(/!\[([^\]]*)\]\((\/{2,})(home\/)/g, '![$1](/home/'); src = src.replace(/!\[([^\]]*)\]\((\/{2,})(home\/)/g, '![$1](/home/');
// Pre-process: normalize link/image destinations for strict-CommonMark markdown-it.
// Handles angle-bracket destinations <url>, optional titles ("..."/'...'/(...)), and the
// non-standard %20 separator some exporters (e.g. Notesnook) emit between url and title.
// Emits clean [label](encoded-url); titles are dropped (we never serialize them anyway).
src = src.replace(
/(!?)(\[[^\]]*\])\(<([^>]*)>(?:(?:\s|%20)*(?:"[^"]*"|'[^']*'|\([^)]*\)))?(?:\s|%20)*\)/g,
(_m, bang, label, url) => `${bang}${label}(${url.replace(/ /g, '%20')})`
);
// Bare destination with a trailing quoted title: [label](url "title") -> drop title, encode spaces.
// Stops our own space-encoder below from mangling valid CommonMark titled links.
src = src.replace(
/(!?)(\[[^\]]*\])\(([^()<>]*?)(?:\s|%20)+(?:"[^"]*"|'[^']*')(?:\s|%20)*\)/g,
(_m, bang, label, url) => `${bang}${label}(${url.replace(/ /g, '%20')})`
);
// Pre-process: percent-encode spaces in image URLs so markdown-it parses them correctly // Pre-process: percent-encode spaces in image URLs so markdown-it parses them correctly
src = src.replace(/!\[([^\]]*)\]\(([^)]*\s[^)]*)\)/g, (match, alt, url) => { src = src.replace(/!\[([^\]]*)\]\(([^)]*\s[^)]*)\)/g, (match, alt, url) => {
return `![${alt}](${url.replace(/ /g, '%20')})`; return `![${alt}](${url.replace(/ /g, '%20')})`;
@@ -3280,6 +3465,11 @@
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- <tiptask checked="false">$2</tiptask>'); src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- <tiptask checked="false">$2</tiptask>');
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false">&nbsp;</tiptask>'); src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false">&nbsp;</tiptask>');
// Pre-process: strip list-separator comments before markdown-it.
// markdown-it treats <!-- --> as an HTML block start, swallowing the
// next line (e.g. an image) as raw HTML instead of parsing it.
src = src.replace(/<!-- -->/g, '\n');
// Pre-process: preserve blank lines before image-only lines // Pre-process: preserve blank lines before image-only lines
// markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph. // markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph.
// Insert a <div> marker that markdown-it passes through (html: true), then convert to <p></p> // Insert a <div> marker that markdown-it passes through (html: true), then convert to <p></p>
@@ -3331,7 +3521,7 @@
if (pendingContent !== null) { if (pendingContent !== null) {
createEditor(pendingContent); createEditor(pendingContent);
pendingContent = null; pendingContent = null;
} else if (isMobile) { } else if (isMobile && !lastSourceMode) {
createEditor(''); createEditor('');
} }
} }
@@ -3392,7 +3582,12 @@
function createEditor(content: string) { function createEditor(content: string) {
if (!editorElement) return; if (!editorElement) return;
destroyEditor(); if (editor) {
editor.destroy();
editor = null;
}
mathObserver?.disconnect();
mathObserver = null;
isLargeDoc = content.length > LARGE_DOC_CHARS; isLargeDoc = content.length > LARGE_DOC_CHARS;
const html = markdownToHtml(content); const html = markdownToHtml(content);
@@ -3929,12 +4124,28 @@
let x = event.clientX; let x = event.clientX;
let y = event.clientY; let y = event.clientY;
const menuWidth = 220; const menuWidth = 220;
const menuHeight = 520; const menuHeight = 600;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8; if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8; if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 4; if (x < 4) x = 4;
if (y < 4) y = 4; if (y < 4) y = 4;
tableContextMenu = { x, y }; let hasStyling = false;
if (editor) {
const pos = editor.view.posAtDOM(event.target as Node, 0);
let resolved = editor.state.doc.resolve(pos);
for (let d = resolved.depth; d >= 0; d--) {
if (resolved.node(d).type.name === 'table') {
resolved.node(d).descendants((n: any) => {
if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
if (n.attrs.backgroundColor || (n.attrs.colspan > 1) || (n.attrs.rowspan > 1)) hasStyling = true;
}
return true;
});
break;
}
}
}
tableContextMenu = { x, y, hasStyling };
return; return;
} }
// Show text context menu for any right-click in editor // Show text context menu for any right-click in editor
@@ -3949,7 +4160,9 @@
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8; if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 4; if (x < 4) x = 4;
if (y < 4) y = 4; if (y < 4) y = 4;
textContextMenu = { x, y }; const submenuWidth = 150;
const submenuLeft = x + menuWidth + submenuWidth > window.innerWidth;
textContextMenu = { x, y, submenuLeft };
} }
function closeTextContextMenu() { function closeTextContextMenu() {
@@ -4615,27 +4828,53 @@
if (!loadedPath) return; if (!loadedPath) return;
if (isSource && !lastSourceMode) { if (isSource && !lastSourceMode) {
// Switching TO source: extract markdown from editor // Switching TO source: extract markdown, then drop the caret on the same word (#125).
const caretNonWs = editor
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
: 0;
const docNonWs = docNonWhitespace();
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? ''); sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
resetSourceHistory(sourceContent); resetSourceHistory(sourceContent);
lastSourceMode = true; lastSourceMode = true;
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
tick().then(() => {
if (!sourceElement) return;
if (!isMobile) sourceElement.focus();
sourceElement.setSelectionRange(target, target);
scrollSourceToOffset(target);
});
} else if (!isSource && lastSourceMode) { } else if (!isSource && lastSourceMode) {
lastSourceMode = false; lastSourceMode = false;
// Capture the source caret before the textarea is torn down (#125).
const srcOffset = sourceElement ? sourceElement.selectionStart : 0;
const srcText = sourceContent;
// Re-anchor the caret in the rich editor once it holds the new content.
const restoreRichCaret = () => {
if (!editor) return;
const nwBefore = scanAlign(srcText, docNonWhitespace(), { limit: srcOffset }).nwCount;
const pos = Math.min(docPosForNonWsCount(editor.state.doc, nwBefore), editor.state.doc.content.size);
const sel = TextSelection.near(editor.state.doc.resolve(pos));
editor.view.dispatch(editor.state.tr.setSelection(sel).scrollIntoView());
if (!isMobile) editor.view.focus();
requestAnimationFrame(() => editor?.commands.scrollIntoView());
};
if (isMobile) { if (isMobile) {
// Mobile: editor stays in DOM, just update its content // Mobile: editor stays in DOM, just update its content
const content = sourceContent || ($activeNote?.content ?? ''); const content = srcText || ($activeNote?.content ?? '');
if (editor) { if (editor) {
ignoreNextUpdate = true; ignoreNextUpdate = true;
editor.commands.setContent(markdownToHtml(content)); editor.commands.setContent(markdownToHtml(content));
tick().then(restoreRichCaret);
} }
} else { } else {
// Desktop: destroy old editor (its DOM element is gone), // Desktop: destroy old editor (its DOM element is gone),
// wait for DOM to swap textarea→div, then create editor on new element. // wait for DOM to swap textarea→div, then create editor on new element.
destroyEditor(); destroyEditor();
const content = sourceContent || ($activeNote?.content ?? ''); const content = srcText || ($activeNote?.content ?? '');
tick().then(() => { tick().then(() => {
if (editorElement && !editor) { if (editorElement && !editor) {
createEditor(content); createEditor(content);
restoreRichCaret();
} }
}); });
} }
@@ -5260,22 +5499,30 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg>
File File
</button> </button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
Horizontal Rule
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleCodeBlock().run(); }}> <button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleCodeBlock().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
Code Block Code Block
</button> </button>
<button onclick={() => { insertDropdown = false; openSecretInsert(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Secret
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleBlockquote().run(); }}> <button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleBlockquote().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
Quote Quote
</button> </button>
<button onclick={() => { insertDropdown = false; tablePickerOpen = true; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
Table
</button>
<button onclick={() => { insertDropdown = false; insertDetails(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
Collapsible Section
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
Horizontal Rule
</button>
<button onclick={() => { insertDropdown = false; openSecretInsert(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Secret
</button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -5334,6 +5581,53 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
</button> </button>
<!-- Indent / Outdent (no Tab key on mobile soft keyboards) -->
<button class="fmt-btn" onclick={() => {
if (!editor) return;
// Try list indent first - run() returns true if it succeeded
const sank = editor.chain().focus().sinkListItem('listItem').run();
if (!sank) {
const sankTask = editor.chain().focus().sinkListItem('taskItem').run();
if (!sankTask && editor.state.selection.empty) {
editor.chain().focus().insertContent('\t').run();
}
}
}} title="Indent">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="4" x2="21" y2="4"/><line x1="11" y1="9" x2="21" y2="9"/><line x1="11" y1="14" x2="21" y2="14"/><line x1="3" y1="19" x2="21" y2="19"/><polyline points="3 9 7 11.5 3 14"/></svg>
</button>
<button class="fmt-btn" onclick={() => {
if (!editor) return;
const lifted = editor.chain().focus().liftListItem('listItem').run();
if (!lifted) {
const liftedTask = editor.chain().focus().liftListItem('taskItem').run();
if (!liftedTask && editor.state.selection.empty) {
// Remove leading tab/spaces from current line
const { from } = editor.state.selection;
const pos = editor.state.doc.resolve(from);
const lineStart = pos.start(pos.depth);
const lineText = editor.state.doc.textBetween(lineStart, pos.end(pos.depth));
if (lineText.startsWith('\t')) {
editor.chain().focus().command(({ tr }) => {
tr.delete(lineStart, lineStart + 1);
return true;
}).run();
} else if (lineText.startsWith(' ')) {
editor.chain().focus().command(({ tr }) => {
tr.delete(lineStart, lineStart + 4);
return true;
}).run();
} else if (lineText.startsWith(' ')) {
editor.chain().focus().command(({ tr }) => {
tr.delete(lineStart, lineStart + 2);
return true;
}).run();
}
}
}
}} title="Outdent">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="4" x2="21" y2="4"/><line x1="11" y1="9" x2="21" y2="9"/><line x1="11" y1="14" x2="21" y2="14"/><line x1="3" y1="19" x2="21" y2="19"/><polyline points="7 9 3 11.5 7 14"/></svg>
</button>
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Highlight --> <!-- Highlight -->
@@ -5386,30 +5680,14 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg>
File File
</button> </button>
<button onclick={() => { insertDropdown = false; tablePickerOpen = true; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
Table
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}> <button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
Horizontal Rule Horizontal Rule
</button> </button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleCodeBlock().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
Code Block
</button>
<button onclick={() => { insertDropdown = false; openSecretInsert(); }}> <button onclick={() => { insertDropdown = false; openSecretInsert(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Secret Secret
</button> </button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleBlockquote().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
Quote
</button>
<button onclick={() => { insertDropdown = false; insertDetails(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
Collapsible Section
</button>
<button onclick={() => { insertDropdown = false; insertTimestamp('datetime'); }}> <button onclick={() => { insertDropdown = false; insertTimestamp('datetime'); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>
Date &amp; Time Date &amp; Time
@@ -5784,7 +6062,7 @@
<svg class="submenu-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg> <svg class="submenu-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg>
</button> </button>
{#if ctxHeadingSubmenu} {#if ctxHeadingSubmenu}
<div class="text-ctx-submenu"> <div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
<button class:active={(editorState, editor?.isActive('heading', { level: 1 }))} onclick={() => ctxSetHeading(1)}>Heading 1</button> <button class:active={(editorState, editor?.isActive('heading', { level: 1 }))} onclick={() => ctxSetHeading(1)}>Heading 1</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</button> <button class:active={(editorState, editor?.isActive('heading', { level: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button> <button class:active={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button>
@@ -5875,7 +6153,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-overlay" onclick={closeTableContextMenu}> <div class="table-ctx-overlay" onclick={closeTableContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px" onclick={(e) => e.stopPropagation()}> <div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px; max-height: calc(100vh - {tableContextMenu.y}px - 8px); overflow-y: auto;" onclick={(e) => e.stopPropagation()}>
<button onclick={tblAddRowBefore}> <button onclick={tblAddRowBefore}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path d="M12 3v3"/><polyline points="9 4.5 12 2 15 4.5"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path d="M12 3v3"/><polyline points="9 4.5 12 2 15 4.5"/></svg>
Add Row Above Add Row Above
@@ -5932,6 +6210,13 @@
{/each} {/each}
</div> </div>
<div class="table-ctx-sep"></div> <div class="table-ctx-sep"></div>
{#if tableContextMenu.hasStyling}
<button onclick={resetTableToMarkdown} title="Strip cell colors and merged cells, output as plain markdown">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7"/><polyline points="21 4 21 10 15 10"/></svg>
Reset to Markdown
</button>
<div class="table-ctx-sep"></div>
{/if}
<button class="danger" onclick={tblDeleteTable}> <button class="danger" onclick={tblDeleteTable}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
Delete Table Delete Table
@@ -6146,18 +6431,27 @@
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}> <div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}> <div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
<input class="code-lang-search" type="text" placeholder="Search..." bind:this={codeLangInput} bind:value={codeLangSearch} onkeydown={(e) => {
if (e.key === 'Enter' && codeLangFiltered.length > 0) { e.preventDefault(); e.stopPropagation(); selectCodeLang(codeLangFiltered[0]); }
if (e.key === 'Escape') closeCodeLangDropdown();
}} />
{#if !codeLangSearch}
<button <button
class="code-lang-option" class="code-lang-option"
class:active={codeLangDropdown.current === ''} class:active={codeLangDropdown.current === ''}
onclick={() => selectCodeLang('')} onclick={() => selectCodeLang('')}
>auto</button> >auto</button>
{#each codeLanguages as lang} {/if}
{#each codeLangFiltered as lang}
<button <button
class="code-lang-option" class="code-lang-option"
class:active={codeLangDropdown.current === lang} class:active={codeLangDropdown.current === lang}
onclick={() => selectCodeLang(lang)} onclick={() => selectCodeLang(lang)}
>{lang}</button> >{lang}</button>
{/each} {/each}
{#if codeLangFiltered.length === 0}
<div class="code-lang-empty">No match</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
@@ -7178,7 +7472,8 @@
resize: none; resize: none;
outline: none; outline: none;
padding: 0 0 var(--editor-scroll-past-end, 65vh); padding: 0 0 var(--editor-scroll-past-end, 65vh);
margin: 0; margin: 0 auto;
max-width: var(--editor-content-width, none);
user-select: text; user-select: text;
/* Wrap long lines instead of horizontal-scrolling (matches mobile). (issue #100) */ /* Wrap long lines instead of horizontal-scrolling (matches mobile). (issue #100) */
white-space: pre-wrap; white-space: pre-wrap;
@@ -7192,6 +7487,9 @@
Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */ Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */
white-space: pre; white-space: pre;
overflow-x: auto; overflow-x: auto;
/* The line-number gutter is pinned to the left edge, so don't center/cap here. (#137) */
max-width: none;
margin: 0;
} }
.line-numbers-clip { .line-numbers-clip {
@@ -7224,6 +7522,12 @@
.tiptap-wrapper { .tiptap-wrapper {
height: 100%; height: 100%;
user-select: text; user-select: text;
/* Optional reading-width cap (Settings > Styling > Note Width). Default `none` = full
width; when set, the text column is capped and centered. Scrollbar stays at the
panel edge because only the inner content is constrained, not .editor-body. (#137) */
max-width: var(--editor-content-width, none);
margin-left: auto;
margin-right: auto;
} }
:global(.tiptap-wrapper .tiptap) { :global(.tiptap-wrapper .tiptap) {
@@ -7311,15 +7615,15 @@
:global(.tiptap-wrapper .tiptap .code-copy-btn) { :global(.tiptap-wrapper .tiptap .code-copy-btn) {
position: absolute; position: absolute;
top: 6px; top: 5px;
right: 76px; right: 5px;
padding: 3px 6px; padding: 2px 6px;
border-radius: 5px; border-radius: 4px;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: pointer; cursor: pointer;
opacity: 0.35; opacity: 0.4;
z-index: 2; z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -7349,8 +7653,8 @@
:global(.tiptap-wrapper .tiptap pre)::after { :global(.tiptap-wrapper .tiptap pre)::after {
content: attr(data-language); content: attr(data-language);
position: absolute; position: absolute;
top: 6px; top: 5px;
right: 6px; right: 34px;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
background: transparent; background: transparent;
@@ -7358,9 +7662,10 @@
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', monospace;
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0.4;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
transition: opacity 0.15s;
} }
:global(.tiptap-wrapper .tiptap pre[data-language=""])::after { :global(.tiptap-wrapper .tiptap pre[data-language=""])::after {
@@ -7368,7 +7673,7 @@
} }
:global(.tiptap-wrapper .tiptap pre:hover)::after { :global(.tiptap-wrapper .tiptap pre:hover)::after {
opacity: 0.7; opacity: 1;
} }
:global(.tiptap-wrapper .tiptap .secret-block) { :global(.tiptap-wrapper .tiptap .secret-block) {
@@ -7905,12 +8210,36 @@
border-radius: 8px; border-radius: 8px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
padding: 4px; padding: 4px;
max-height: 280px; max-height: 320px;
overflow-y: auto; overflow-y: auto;
min-width: 130px; min-width: 150px;
z-index: 2001; z-index: 2001;
} }
.code-lang-search {
width: 100%;
box-sizing: border-box;
padding: 5px 8px;
margin-bottom: 4px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.code-lang-search:focus {
border-color: var(--accent);
}
.code-lang-empty {
padding: 8px;
text-align: center;
color: var(--text-tertiary);
font-size: 12px;
}
.code-lang-dropdown::-webkit-scrollbar { .code-lang-dropdown::-webkit-scrollbar {
width: 5px; width: 5px;
} }
@@ -8154,6 +8483,14 @@
vertical-align: 15%; vertical-align: 15%;
} }
:global(html.no-link-arrows .tiptap-wrapper .tiptap a::after) {
content: none;
}
:global(html.no-link-arrows .tiptap-wrapper .tiptap a[href$=".md"]::after) {
content: none;
}
:global(.tiptap-wrapper .tiptap a[href$=".md"]::after) { :global(.tiptap-wrapper .tiptap a[href$=".md"]::after) {
content: '⤴'; content: '⤴';
} }
@@ -8608,8 +8945,6 @@
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
padding: 4px; padding: 4px;
min-width: 200px; min-width: 200px;
max-height: calc(100vh - 16px);
overflow-y: auto;
z-index: 1501; z-index: 1501;
} }
@@ -8687,6 +9022,13 @@
z-index: 1502; z-index: 1502;
} }
.text-ctx-submenu.flip-left {
left: auto;
right: 100%;
margin-left: 0;
margin-right: 2px;
}
.text-ctx-submenu button { .text-ctx-submenu button {
display: flex; display: flex;
align-items: center; align-items: center;
+149 -19
View File
@@ -5,10 +5,85 @@
import { openUrl } from '$lib/api'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
import {
ACTIONS,
keybindings,
setBinding,
resetBinding,
bindingFromEvent,
bindingToKeys,
bindingsEqual,
type ActionDef,
} from '$lib/keybindings';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Customizable shortcuts, grouped for display.
const actionGroups: { title: string; actions: ActionDef[] }[] = [
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
];
// id of the action currently listening for a new key combo, or null.
let capturingId = $state<string | null>(null);
// id of an action whose row should briefly flash a "already in use" warning.
let conflictId = $state<string | null>(null);
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
function startCapture(id: string) {
conflictId = null;
capturingId = id;
}
function stopCapture() {
capturingId = null;
}
function flashConflict(id: string) {
conflictId = id;
if (conflictTimer) clearTimeout(conflictTimer);
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
}
function captureKeydown(e: KeyboardEvent) {
if (!capturingId) return;
// Capture phase + stopImmediatePropagation keeps the combo from also
// triggering the app-level shortcut handler on window.
e.preventDefault();
e.stopImmediatePropagation();
if (e.key === 'Escape') { stopCapture(); return; }
const binding = bindingFromEvent(e);
if (!binding) return; // modifier-only press; keep listening
// Reject combos already bound to a different action.
const map = $keybindings;
const target = capturingId;
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
if (clash) {
flashConflict(target);
stopCapture();
return;
}
setBinding(target, binding);
stopCapture();
}
$effect(() => {
if (!capturingId) return;
window.addEventListener('keydown', captureKeydown, true);
return () => window.removeEventListener('keydown', captureKeydown, true);
});
function resetKey(e: MouseEvent, id: string) {
e.preventDefault();
resetBinding(id);
if (capturingId === id) stopCapture();
}
let stats = $state<VaultStats | null>(null); let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...'); let appVersion = $state('...');
@@ -158,27 +233,39 @@
</div> </div>
{:else} {:else}
<div class="shortcuts-section"> <div class="shortcuts-section">
<h4 class="shortcuts-group-title">General</h4> {#if !isMobile}
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div> <p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div> {/if}
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div> {#each actionGroups as group}
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div> <h4 class="shortcuts-group-title">{group.title}</h4>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div> {#each group.actions as action}
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div> <div class="shortcut-row">
<span class="shortcut-desc">{action.label}</span>
<h4 class="shortcuts-group-title">Interface</h4> {#if isMobile}
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div> <span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div> {:else}
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div> <button
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div> class="shortcut-key-btn"
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div> class:capturing={capturingId === action.id}
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div> class:conflict={conflictId === action.id}
title="Click to rebind · right-click to reset"
onclick={() => startCapture(action.id)}
oncontextmenu={(e) => resetKey(e, action.id)}
>
{#if capturingId === action.id}
<span class="capture-prompt">Press keys…</span>
{:else if conflictId === action.id}
<span class="conflict-msg">Already in use</span>
{:else}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{/if}
</button>
{/if}
</div>
{/each}
{/each}
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4> <h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
@@ -189,6 +276,7 @@
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4> <h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
@@ -501,6 +589,48 @@
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.shortcuts-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 4px;
}
.shortcut-key-btn {
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 4px;
margin: -2px -4px;
cursor: pointer;
display: flex;
align-items: center;
flex-shrink: 0;
font: inherit;
}
.shortcut-key-btn:hover {
background: var(--bg-hover);
}
.shortcut-key-btn.capturing {
border-color: var(--accent);
background: var(--accent-light);
}
.shortcut-key-btn.conflict {
border-color: var(--danger, #e11d48);
}
.capture-prompt {
font-size: 12px;
color: var(--accent);
}
.conflict-msg {
font-size: 12px;
color: var(--danger, #e11d48);
}
.shortcut-keys kbd, .shortcut-keys kbd,
.shortcut-desc kbd { .shortcut-desc kbd {
display: inline-block; display: inline-block;
+123 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { import {
notes, notes,
sortedNotes, sortedNotes,
@@ -11,6 +12,8 @@
editorDirty, editorDirty,
quickAccessPaths, quickAccessPaths,
appConfig, appConfig,
notelistCollapsed,
noteOrder,
notebooks, notebooks,
tags, tags,
mobileView mobileView
@@ -143,6 +146,7 @@
let sortMenu = $state<{ x: number; y: number } | null>(null); let sortMenu = $state<{ x: number; y: number } | null>(null);
let editingNote = $state<string | null>(null); let editingNote = $state<string | null>(null);
let editValue = $state(''); let editValue = $state('');
let renameInput: HTMLInputElement | null = $state(null);
let movePickerNote = $state<NoteEntry | null>(null); let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null); let tagEditNote = $state<NoteEntry | null>(null);
let tagEditTags = $state<string[]>([]); let tagEditTags = $state<string[]>([]);
@@ -203,6 +207,11 @@
let qaDragFrom = $state<number | null>(null); let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null); let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom'); let qaDragHalf = $state<'top' | 'bottom'>('bottom');
let noteDragFrom = $state<number | null>(null);
let noteDragOver = $state<number | null>(null);
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
const customOrderViews = new Set(['all', 'notebook', 'tag']);
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
// Virtual scroll // Virtual scroll
let listContainer = $state<HTMLDivElement>(null!); let listContainer = $state<HTMLDivElement>(null!);
@@ -412,6 +421,7 @@
} }
try { try {
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear(); noteCache.clear();
await refresh(); await refresh();
await selectNote(entry); await selectNote(entry);
@@ -429,6 +439,7 @@
try { try {
const newTitle = editValue.trim(); const newTitle = editValue.trim();
const newPath = await renameNote(note.path, newTitle); const newPath = await renameNote(note.path, newTitle);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear(); noteCache.clear();
editingNote = null; editingNote = null;
// Update local store immediately (avoid full vault re-scan) // Update local store immediately (avoid full vault re-scan)
@@ -450,6 +461,7 @@
contextMenu = null; contextMenu = null;
try { try {
await deleteNote(note.path); await deleteNote(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear(); noteCache.clear();
// Remove from local store immediately (avoid full vault re-scan) // Remove from local store immediately (avoid full vault re-scan)
$notes = $notes.filter(n => n.path !== note.path); $notes = $notes.filter(n => n.path !== note.path);
@@ -477,6 +489,7 @@
contextMenu = null; contextMenu = null;
try { try {
await permanentDelete(note.path); await permanentDelete(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear(); noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path); $notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) { if ($activeNotePath === note.path) {
@@ -670,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) { async function handleMoveNote(note: NoteEntry, destPath: string) {
contextMenu = null; contextMenu = null;
movePickerNote = null; movePickerNote = null;
try { try {
const newPath = await moveNote(note.path, destPath); const newPath = await moveNote(note.path, destPath);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear(); noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path); $notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) { if ($activeNotePath === note.path) {
@@ -701,10 +763,13 @@
contextMenu = { x, y, note }; contextMenu = { x, y, note };
} }
function startRename(note: NoteEntry) { async function startRename(note: NoteEntry) {
contextMenu = null; contextMenu = null;
editingNote = note.path; editingNote = note.path;
editValue = note.meta.title; editValue = note.meta.title;
await tick();
renameInput?.focus();
renameInput?.select();
} }
function getNotebookPath(note: NoteEntry): string { function getNotebookPath(note: NoteEntry): string {
@@ -773,6 +838,7 @@
async function handleBatchDelete() { async function handleBatchDelete() {
const toDelete = new Set(selectedPaths); const toDelete = new Set(selectedPaths);
noteCache.clear(); noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path)); $notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) { if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null; $activeNote = null;
@@ -808,6 +874,7 @@
async function handleBatchPermanentDelete() { async function handleBatchPermanentDelete() {
const toDelete = new Set(selectedPaths); const toDelete = new Set(selectedPaths);
noteCache.clear(); noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path)); $notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) { if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null; $activeNote = null;
@@ -833,6 +900,9 @@
} }
function setSortMode(mode: SortMode) { function setSortMode(mode: SortMode) {
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
persistManualNoteOrder($sortedNotes);
}
$sortMode = mode; $sortMode = mode;
sortMenu = null; sortMenu = null;
} }
@@ -856,6 +926,16 @@
<div class="list-header"> <div class="list-header">
<span class="list-title">{viewTitle}</span> <span class="list-title">{viewTitle}</span>
<div class="list-actions"> <div class="list-actions">
{#if !isMobile}
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="16" y2="6" />
<line x1="4" y1="12" x2="16" y2="12" />
<line x1="4" y1="18" x2="16" y2="18" />
<polyline points="19 8 21 12 19 16" />
</svg>
</button>
{/if}
{#if $viewMode !== 'tasks'} {#if $viewMode !== 'tasks'}
{#if isAndroid} {#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select"> <button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
@@ -1055,6 +1135,7 @@
<input <input
type="text" type="text"
class="rename-input" class="rename-input"
bind:this={renameInput}
bind:value={editValue} bind:value={editValue}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') handleRename(note); if (e.key === 'Enter') handleRename(note);
@@ -1072,7 +1153,15 @@
class:compact={compact} class:compact={compact}
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'} class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'} class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(note);
}
}}
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }} ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }} ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }} ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
@@ -1099,6 +1188,12 @@
e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.effectAllowed = 'move';
return; return;
} }
if (canCustomReorder) {
noteDragFrom = noteIndex;
e.dataTransfer!.setData('text/plain', note.path);
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) { if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n')); e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else { } else {
@@ -1113,18 +1208,28 @@
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom'; qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
qaDragOver = noteIndex; qaDragOver = noteIndex;
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
noteDragOver = noteIndex;
} }
}} }}
ondragleave={() => { ondragleave={() => {
if (qaDragOver === noteIndex) qaDragOver = null; if (qaDragOver === noteIndex) qaDragOver = null;
if (noteDragOver === noteIndex) noteDragOver = null;
}} }}
ondrop={(e) => { ondrop={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) { if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault(); e.preventDefault();
handleQaDrop(noteIndex); handleQaDrop(noteIndex);
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
handleCustomNoteDrop(noteIndex);
} }
}} }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }} ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
> >
{#if compact} {#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1403,6 +1508,18 @@
Title Title
{#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if} {#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if}
</button> </button>
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="4" cy="6" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="4" cy="18" r="1" />
</svg>
Custom
{#if $sortMode === 'custom'}<span class="sort-check">&#10003;</span>{/if}
</button>
</div> </div>
{/if} {/if}
@@ -1925,11 +2042,13 @@
font-size: 14px; font-size: 14px;
} }
.note-item.qa-drag-above { .note-item.qa-drag-above,
.note-item.note-drag-above {
border-top: 2px solid var(--accent); border-top: 2px solid var(--accent);
} }
.note-item.qa-drag-below { .note-item.qa-drag-below,
.note-item.note-drag-below {
border-bottom: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
} }
+7 -7
View File
@@ -12,10 +12,13 @@
theme theme
} from '$lib/stores/app'; } from '$lib/stores/app';
import { readNote } from '$lib/api'; import { readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
import type { FileEvent } from '$lib/types'; import type { FileEvent } from '$lib/types';
let { notePath }: { notePath: string } = $props(); let { notePath }: { notePath: string } = $props();
import { darkThemes } from '$lib/platform';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!); let editor = $state<Editor>(null!);
@@ -35,8 +38,7 @@
$effect(() => { $effect(() => {
const t = $theme || 'system'; const t = $theme || 'system';
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson']; const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); root.classList.remove('dark');
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
@@ -72,13 +74,11 @@
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const action = matchAction(e, $keybindings);
if (mod && !e.shiftKey && e.code === 'KeyS') { if (action === 'save') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return; } else if (action === 'toggle-source') {
}
if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
+124 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall } from '$lib/stores/app'; 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, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api'; 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 { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
@@ -9,6 +10,7 @@
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); 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'; type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
let activeTab = $state<Tab>('styling'); let activeTab = $state<Tab>('styling');
@@ -384,7 +386,6 @@
} }
} }
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'];
let isThemeDark = $derived( let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
); );
@@ -422,6 +423,7 @@
{ id: 'horizon', label: 'Horizon', bg: '#1c1e26', sidebar: '#16181f', accent: '#e9547f' }, { id: 'horizon', label: 'Horizon', bg: '#1c1e26', sidebar: '#16181f', accent: '#e9547f' },
{ id: 'cyberpunk', label: 'Cyberpunk', bg: '#0a0e17', sidebar: '#060911', accent: '#ff0080' }, { id: 'cyberpunk', label: 'Cyberpunk', bg: '#0a0e17', sidebar: '#060911', accent: '#ff0080' },
{ id: 'black', label: 'Black', bg: '#000000', sidebar: '#0a0a0a', accent: '#ffffff' }, { id: 'black', label: 'Black', bg: '#000000', sidebar: '#0a0a0a', accent: '#ffffff' },
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
]; ];
const accentPresets = [ const accentPresets = [
@@ -476,12 +478,20 @@
{ label: '200%', value: 2 }, { 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 activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf'); let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14); let activeFontSize = $state($appConfig?.font_size ?? 14);
let activeFontFamily = $state($appConfig?.font_family ?? 'system'); let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6); let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1); let activeUiScale = $state($appConfig?.ui_scale ?? 1);
let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null);
let themeDropdownOpen = $state(false); let themeDropdownOpen = $state(false);
let accentDropdownOpen = $state(false); let accentDropdownOpen = $state(false);
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]); let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
@@ -507,11 +517,19 @@
let titleMode = $state($appConfig?.title_mode ?? 'input'); let titleMode = $state($appConfig?.title_mode ?? 'input');
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false); let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
let showLineNumbers = $state($appConfig?.show_line_numbers ?? 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 defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false); let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false); let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true); 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 = [ const pdfHeightPresets = [
{ label: 'Small', value: 300 }, { label: 'Small', value: 300 },
{ label: 'Medium', value: 450 }, { label: 'Medium', value: 450 },
@@ -564,15 +582,32 @@
$appConfig.title_mode = titleMode; $appConfig.title_mode = titleMode;
$appConfig.hide_title_in_body = hideTitleInBody; $appConfig.hide_title_in_body = hideTitleInBody;
$appConfig.show_line_numbers = showLineNumbers; $appConfig.show_line_numbers = showLineNumbers;
$appConfig.show_link_arrows = showLinkArrows;
$appConfig.default_view_mode = defaultViewMode; $appConfig.default_view_mode = defaultViewMode;
$appConfig.show_tray_icon = showTrayIcon; $appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray; $appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks; $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, dailyTitleFormat, 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)); .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]) { function selectAccent(preset: typeof accentPresets[0]) {
activeAccent = preset.name; activeAccent = preset.name;
applyAccent(preset); applyAccent(preset);
@@ -667,6 +702,17 @@
} }
} }
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 clickOutside(node: HTMLElement, callback: () => void) {
function handle(e: MouseEvent) { function handle(e: MouseEvent) {
if (!node.contains(e.target as Node)) callback(); if (!node.contains(e.target as Node)) callback();
@@ -718,6 +764,11 @@
activeLineHeight = savedLineHeight; activeLineHeight = savedLineHeight;
applyLineHeight(savedLineHeight); applyLineHeight(savedLineHeight);
} }
const savedContentWidth = $appConfig?.content_width;
if (savedContentWidth) {
activeContentWidth = savedContentWidth;
applyContentWidth(savedContentWidth);
}
// Sync general settings // Sync general settings
if ($appConfig) { if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false; compactNotes = $appConfig.compact_notes ?? false;
@@ -731,6 +782,11 @@
pdfPreview = $appConfig.pdf_preview ?? false; pdfPreview = $appConfig.pdf_preview ?? false;
pdfHeight = $appConfig.pdf_height ?? 600; pdfHeight = $appConfig.pdf_height ?? 600;
titleMode = $appConfig.title_mode ?? 'input'; 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;
} }
}); });
@@ -854,6 +910,43 @@
</div> </div>
</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"> <div class="settings-section">
<h3>Time Format</h3> <h3>Time Format</h3>
<div class="setting-options"> <div class="setting-options">
@@ -956,6 +1049,15 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </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} {#if !isMobile}
<label class="setting-toggle" style="margin-top: 12px;"> <label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label"> <span class="setting-label">
@@ -1061,7 +1163,7 @@
<button <button
class="dropdown-item" class="dropdown-item"
class:active={$theme === preset.id} class:active={$theme === preset.id}
onclick={() => { $theme = preset.id; setTheme(preset.id); themeDropdownOpen = false; }} onclick={() => selectTheme(preset)}
> >
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}"> <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-sidebar"></span>
@@ -1162,6 +1264,24 @@
</div> </div>
{/if} {/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"> <div class="settings-section">
<h3>Line Height</h3> <h3>Line Height</h3>
<div class="font-size-options"> <div class="font-size-options">
+166 -28
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { import {
notebooks, notebooks,
notes, notes,
@@ -20,7 +21,7 @@
notebookSortMode, notebookSortMode,
notebookOrder notebookOrder
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api'; import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
@@ -33,10 +34,22 @@
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
const anyNavItem = $derived(
$appConfig?.show_all_notes !== false ||
$appConfig?.show_quick_access !== false ||
$appConfig?.show_tasks !== false ||
$appConfig?.show_daily_notes !== false ||
$appConfig?.show_trash !== false
);
let editingNotebook = $state<string | null>(null); let editingNotebook = $state<string | null>(null);
let editValue = $state(''); let editValue = $state('');
let renameInput: HTMLInputElement | null = $state(null);
let newNotebookName = $state(''); let newNotebookName = $state('');
let showNewNotebook = $state(false); let showNewNotebook = $state(false);
let newNotebookInput: HTMLInputElement | null = $state(null);
let dropTargetPath = $state<string | null>(null); let dropTargetPath = $state<string | null>(null);
let dropPosition = $state<'above' | 'into' | 'below' | null>(null); let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
let draggedNotebookPath = $state<string | null>(null); let draggedNotebookPath = $state<string | null>(null);
@@ -59,19 +72,25 @@
let trashContextMenu = $state<{ x: number; y: number } | null>(null); let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true); let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null); let deleteConfirm = $state<NotebookEntry | null>(null);
function toggleCollapse(path: string, e: MouseEvent) { function expandNotebook(nb: NotebookEntry) {
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
}
}
function toggleCollapse(nb: NotebookEntry, e: Event) {
e.stopPropagation(); e.stopPropagation();
if ($collapsedNotebooks.includes(path)) { if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== path); $collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
} else { } else {
$collapsedNotebooks = [...$collapsedNotebooks, path]; $collapsedNotebooks = [...$collapsedNotebooks, nb.path];
} }
} }
// Collapse-all / expand-all: toggles every notebook that has children. // Collapse-all / expand-all: toggles every notebook that has children.
function collectParentPaths(tree: NotebookEntry[]): string[] { function collectParentPaths(tree: NotebookEntry[]): string[] {
const out: string[] = []; const out: string[] = [];
for (const nb of tree) { for (const nb of tree) {
if (nb.children && nb.children.length > 0) { if (nb.children.length > 0) {
out.push(nb.path); out.push(nb.path);
out.push(...collectParentPaths(nb.children)); out.push(...collectParentPaths(nb.children));
} }
@@ -133,12 +152,23 @@
onViewChanged(); 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 // Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) { if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
if (hasVisibleChildren) {
if (wasCollapsed) {
await expandNotebook(nb);
} else {
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
}
return;
}
selectAllNotes(); selectAllNotes();
return; return;
} }
if (hasVisibleChildren) await expandNotebook(nb);
$viewMode = 'notebook'; $viewMode = 'notebook';
$activeNotebook = nb; $activeNotebook = nb;
$activeTag = null; $activeTag = null;
@@ -182,11 +212,52 @@
let newNotebookParent = $state<NotebookEntry | null>(null); let newNotebookParent = $state<NotebookEntry | null>(null);
function getNotebookNameSegments(name: string): string[] | null {
const segments = name.split('/').map((segment) => segment.trim());
if (segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) {
return null;
}
return segments;
}
async function focusNewNotebookInput() {
await tick();
newNotebookInput?.focus();
}
async function toggleNewNotebookInput() {
if (showNewNotebook) {
showNewNotebook = false;
newNotebookName = '';
newNotebookParent = null;
return;
}
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
await focusNewNotebookInput();
}
async function startNewNotebookFromSidebar(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .context-menu, .delete-confirm-overlay')) return;
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
newNotebookName = '';
showNewNotebook = true;
await focusNewNotebookInput();
}
async function handleCreateNotebook() { async function handleCreateNotebook() {
if (!newNotebookName.trim()) return; if (!newNotebookName.trim()) return;
try { try {
const trimmedName = newNotebookName.trim();
const parentRel = newNotebookParent?.relative_path ?? null; const parentRel = newNotebookParent?.relative_path ?? null;
await createNotebook(parentRel, newNotebookName.trim()); const name = parentRel ? trimmedName : getNotebookNameSegments(trimmedName)?.join('/');
if (!name) return;
await createNotebook(parentRel, name);
newNotebookName = ''; newNotebookName = '';
showNewNotebook = false; showNewNotebook = false;
newNotebookParent = null; newNotebookParent = null;
@@ -196,7 +267,24 @@
} }
} }
function startNewSubNotebook(nb: NotebookEntry) { function handleNewNotebookKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleCreateNotebook();
return;
}
if (e.key === 'Escape') {
showNewNotebook = false;
newNotebookName = '';
newNotebookParent = null;
return;
}
if (e.key === 'Backspace' && newNotebookParent && newNotebookName.length === 0) {
e.preventDefault();
newNotebookParent = null;
}
}
async function startNewSubNotebook(nb: NotebookEntry) {
contextMenu = null; contextMenu = null;
newNotebookParent = nb; newNotebookParent = nb;
newNotebookName = ''; newNotebookName = '';
@@ -205,6 +293,7 @@
if ($collapsedNotebooks.includes(nb.path)) { if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path); $collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
} }
await focusNewNotebookInput();
} }
async function handleRename(nb: NotebookEntry) { async function handleRename(nb: NotebookEntry) {
@@ -394,10 +483,13 @@
} }
} }
function startRename(nb: NotebookEntry) { async function startRename(nb: NotebookEntry) {
contextMenu = null; contextMenu = null;
editingNotebook = nb.path; editingNotebook = nb.path;
editValue = nb.name; editValue = nb.name;
await tick();
renameInput?.focus();
renameInput?.select();
} }
async function handleSetIcon(nb: NotebookEntry) { async function handleSetIcon(nb: NotebookEntry) {
@@ -486,7 +578,7 @@
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}> <aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem} ondblclick={startNewNotebookFromSidebar}>
{#if !isMobile} {#if !isMobile}
<div class="sidebar-header"> <div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar"> <button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
@@ -498,19 +590,19 @@
{/if} {/if}
</svg> </svg>
</button> </button>
{#if !$sidebarCollapsed}
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}> <button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</button> </button>
{/if}
</div> </div>
{/if} {/if}
{#if !$sidebarCollapsed} {#if !$sidebarCollapsed}
{#if anyNavItem}
<nav class="sidebar-nav"> <nav class="sidebar-nav">
{#if $appConfig?.show_all_notes !== false}
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'all'} class:active={$viewMode === 'all'}
@@ -522,7 +614,9 @@
</svg> </svg>
<span>All Notes</span> <span>All Notes</span>
</button> </button>
{/if}
{#if $appConfig?.show_quick_access !== false}
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'quickaccess'} class:active={$viewMode === 'quickaccess'}
@@ -533,7 +627,9 @@
</svg> </svg>
<span>Quick Access</span> <span>Quick Access</span>
</button> </button>
{/if}
{#if $appConfig?.show_tasks !== false}
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'tasks'} class:active={$viewMode === 'tasks'}
@@ -545,7 +641,9 @@
</svg> </svg>
<span>Tasks</span> <span>Tasks</span>
</button> </button>
{/if}
{#if $appConfig?.show_daily_notes !== false}
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'daily'} class:active={$viewMode === 'daily'}
@@ -559,7 +657,9 @@
</svg> </svg>
<span>Daily Notes</span> <span>Daily Notes</span>
</button> </button>
{/if}
{#if $appConfig?.show_trash !== false}
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'trash'} class:active={$viewMode === 'trash'}
@@ -571,7 +671,9 @@
</svg> </svg>
<span>Trash</span> <span>Trash</span>
</button> </button>
{/if}
</nav> </nav>
{/if}
<div class="section"> <div class="section">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -606,15 +708,7 @@
</svg> </svg>
</button> </button>
{/if} {/if}
<button class="icon-btn-sm" onclick={() => { <button class="icon-btn-sm" onclick={toggleNewNotebookInput} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
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'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -630,13 +724,11 @@
</button> </button>
{/if} {/if}
<input <input
bind:this={newNotebookInput}
type="text" type="text"
bind:value={newNotebookName} bind:value={newNotebookName}
placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'} placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
onkeydown={(e) => { onkeydown={handleNewNotebookKeydown}
if (e.key === 'Enter') handleCreateNotebook();
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; }
}}
/> />
</div> </div>
{/if} {/if}
@@ -708,6 +800,7 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{/if}
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="icon-btn" onclick={() => ($showInfo = true)} title="Info"> <button class="icon-btn" onclick={() => ($showInfo = true)} title="Info">
@@ -727,7 +820,6 @@
</div> </div>
</div> </div>
{/if}
</aside> </aside>
{#if contextMenu} {#if contextMenu}
@@ -794,6 +886,7 @@
<input <input
type="text" type="text"
class="rename-input" class="rename-input"
bind:this={renameInput}
bind:value={editValue} bind:value={editValue}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') handleRename(nb); if (e.key === 'Enter') handleRename(nb);
@@ -812,6 +905,12 @@
style="padding-left: {8 + depth * 16}px" style="padding-left: {8 + depth * 16}px"
draggable="true" draggable="true"
onclick={() => selectNotebook(nb)} onclick={() => selectNotebook(nb)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(nb);
}
}}
oncontextmenu={(e) => onContextMenu(e, nb)} oncontextmenu={(e) => onContextMenu(e, nb)}
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }} ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }} ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
@@ -863,7 +962,7 @@
> >
{#if hasChildren} {#if hasChildren}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb.path, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb.path, e); }}> <span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb, e); }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
@@ -906,6 +1005,29 @@
max-width: 44px; max-width: 44px;
} }
/* Collapsed rail: stack the toolbar icons vertically - search under the expand chevron at
the top, info + settings pinned to the bottom. */
.sidebar.collapsed .sidebar-header {
flex-direction: column;
justify-content: flex-start;
gap: 4px;
padding: 8px 4px;
border-bottom: none;
}
.sidebar.collapsed .sidebar-footer {
flex-direction: column;
align-items: center;
gap: 4px;
margin-top: auto;
padding: 8px 4px;
border-top: none;
}
.sidebar.collapsed .sidebar-footer-right {
flex-direction: column;
}
.sidebar-header { .sidebar-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -949,6 +1071,22 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 0; 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 { .tags-section {
+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 isAndroid = /android/i.test(ua);
export const isIOS = /iphone|ipad|ipod/i.test(ua); export const isIOS = /iphone|ipad|ipod/i.test(ua);
export const isMobile = isAndroid || isIOS; 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);
}
+24 -2
View File
@@ -21,6 +21,7 @@ export const tasksHideCompleted = writable<boolean>(true);
export const tasksOnlyFlagged = writable<boolean>(false); export const tasksOnlyFlagged = writable<boolean>(false);
export const tasksSort = writable<"due" | "priority" | "note">("due"); export const tasksSort = writable<"due" | "priority" | "note">("due");
export const sidebarCollapsed = writable(false); export const sidebarCollapsed = writable(false);
export const notelistCollapsed = writable(false);
export const sidebarWidth = writable(220); export const sidebarWidth = writable(220);
export const notelistWidth = writable(280); export const notelistWidth = writable(280);
export const searchQuery = writable(""); export const searchQuery = writable("");
@@ -39,6 +40,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">(
); );
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'. // Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
export const notebookOrder = writable<Record<string, number>>({}); export const notebookOrder = writable<Record<string, number>>({});
export const noteOrder = writable<Record<string, number>>({});
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
@@ -176,16 +178,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode, noteOrder],
([$notes, $sortMode, $viewMode]) => { ([$notes, $sortMode, $viewMode, $noteOrder]) => {
// Quick Access preserves stored order // Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes; if ($viewMode === "quickaccess") return $notes;
const customSortable =
$viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag";
const pinned = $notes.filter((n) => n.meta.pinned); const pinned = $notes.filter((n) => n.meta.pinned);
const unpinned = $notes.filter((n) => !n.meta.pinned); const unpinned = $notes.filter((n) => !n.meta.pinned);
const sortFn = (a: NoteEntry, b: NoteEntry) => { const sortFn = (a: NoteEntry, b: NoteEntry) => {
switch ($sortMode) { switch ($sortMode) {
case "custom": {
if (!customSortable) {
return (
new Date(b.meta.modified).getTime() -
new Date(a.meta.modified).getTime()
);
}
const oa = $noteOrder[a.path] ?? Number.MAX_SAFE_INTEGER;
const ob = $noteOrder[b.path] ?? Number.MAX_SAFE_INTEGER;
if (oa !== ob) return oa - ob;
return a.meta.title.localeCompare(b.meta.title);
}
case "title": case "title":
return a.meta.title.localeCompare(b.meta.title); return a.meta.title.localeCompare(b.meta.title);
case "created": case "created":
@@ -212,27 +228,33 @@ export const vaultState = derived(
sidebarWidth, sidebarWidth,
notelistWidth, notelistWidth,
sidebarCollapsed, sidebarCollapsed,
notelistCollapsed,
collapsedNotebooks, collapsedNotebooks,
notebookSortMode, notebookSortMode,
notebookOrder, notebookOrder,
noteOrder,
], ],
([ ([
$activeNotePath, $activeNotePath,
$sidebarWidth, $sidebarWidth,
$notelistWidth, $notelistWidth,
$sidebarCollapsed, $sidebarCollapsed,
$notelistCollapsed,
$collapsedNotebooks, $collapsedNotebooks,
$notebookSortMode, $notebookSortMode,
$notebookOrder, $notebookOrder,
$noteOrder,
]) => { ]) => {
return { return {
last_open_note: $activeNotePath, last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth, sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode, notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder, notebook_order: $notebookOrder,
note_order: $noteOrder,
} satisfies VaultState; } satisfies VaultState;
}, },
); );
+10 -1
View File
@@ -55,6 +55,7 @@ export interface AppConfig {
font_family: string | null; font_family: string | null;
line_height: number | null; line_height: number | null;
ui_scale: number | null; ui_scale: number | null;
content_width: number | null;
compact_notes: boolean; compact_notes: boolean;
show_note_dates: boolean; show_note_dates: boolean;
time_format: string; time_format: string;
@@ -67,6 +68,12 @@ export interface AppConfig {
title_mode: string; title_mode: string;
hide_title_in_body: boolean; hide_title_in_body: boolean;
show_line_numbers: boolean; show_line_numbers: boolean;
show_link_arrows: boolean;
show_all_notes: boolean;
show_quick_access: boolean;
show_tasks: boolean;
show_daily_notes: boolean;
show_trash: boolean;
backup_enabled: boolean; backup_enabled: boolean;
backup_frequency: string; backup_frequency: string;
backup_max_count: number; backup_max_count: number;
@@ -103,9 +110,11 @@ export interface VaultState {
sidebar_width: number; sidebar_width: number;
notelist_width: number; notelist_width: number;
sidebar_collapsed: boolean; sidebar_collapsed: boolean;
notelist_collapsed: boolean;
collapsed_notebooks: string[]; collapsed_notebooks: string[];
notebook_sort_mode?: string; notebook_sort_mode?: string;
notebook_order?: Record<string, number>; notebook_order?: Record<string, number>;
note_order?: Record<string, number>;
sort_mode?: string; sort_mode?: string;
last_view_mode?: string; last_view_mode?: string;
last_notebook?: string | null; last_notebook?: string | null;
@@ -164,7 +173,7 @@ export interface NoteTitleEntry {
path: string; path: string;
} }
export type SortMode = "modified" | "title" | "created"; export type SortMode = "modified" | "title" | "created" | "custom";
export type ViewMode = export type ViewMode =
| "all" | "all"
| "notebook" | "notebook"
+9 -2
View File
@@ -4,6 +4,7 @@
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api'; import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { darkThemes } from '$lib/platform';
import ResizeHandles from '$lib/components/ResizeHandles.svelte'; import ResizeHandles from '$lib/components/ResizeHandles.svelte';
let { children } = $props(); let { children } = $props();
@@ -15,9 +16,15 @@
applyTheme($theme); 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) { function applyTheme(t: string) {
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']; const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', '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'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); root.classList.remove('dark');
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
+5 -2
View File
@@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app'; import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, setFontSize } from '$lib/api'; import { getAppConfig, openVault, setFontSize } from '$lib/api';
import { darkThemes } from '$lib/platform';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import VaultPicker from '$lib/components/VaultPicker.svelte'; import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte'; import AppLayout from '$lib/components/AppLayout.svelte';
@@ -120,8 +121,7 @@
// Apply theme immediately to prevent flash // Apply theme immediately to prevent flash
const themeValue = config.theme || 'system'; const themeValue = config.theme || 'system';
const root = document.documentElement; const root = document.documentElement;
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']; const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
root.classList.remove('dark'); root.classList.remove('dark');
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
if (namedThemes.includes(themeValue)) { if (namedThemes.includes(themeValue)) {
@@ -138,6 +138,9 @@
if (config.line_height) { if (config.line_height) {
document.documentElement.style.setProperty('--editor-line-height', String(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) { if (config.font_family) {
const fontStacks: Record<string, string> = { const fontStacks: Record<string, string> = {
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',