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
[![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)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download (v1.3.1)
## Download (v1.3.2)
### Linux
@@ -92,23 +92,23 @@ sudo eopkg it helixnotes
#### 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)
[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
[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
[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
[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;
src = ./.;
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -59,6 +59,7 @@
"@tiptap/starter-kit": "^3.19.0",
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.1.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.0",
+3
View File
@@ -95,6 +95,9 @@ importers:
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
katex:
specifier: ^0.16.28
version: 0.16.28
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.1"
version = "1.3.2"
dependencies = [
"arboard",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.3.1"
version = "1.3.2"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
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(())
}
#[tauri::command]
pub fn set_content_width(state: State<'_, AppState>, width: Option<u32>) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.content_width = width;
save_app_config(&config)?;
Ok(())
}
// ── Notebooks ──
#[tauri::command]
@@ -1087,17 +1095,28 @@ pub fn set_general_settings(
title_mode: String,
hide_title_in_body: bool,
show_line_numbers: bool,
show_link_arrows: bool,
default_view_mode: bool,
show_tray_icon: bool,
close_to_tray: bool,
enable_wiki_links: bool,
show_note_dates: bool,
restore_last_session: bool,
show_all_notes: bool,
show_quick_access: bool,
show_tasks: bool,
show_daily_notes: bool,
show_trash: bool,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.restore_last_session = restore_last_session;
config.show_all_notes = show_all_notes;
config.show_quick_access = show_quick_access;
config.show_tasks = show_tasks;
config.show_daily_notes = show_daily_notes;
config.show_trash = show_trash;
config.time_format = time_format;
config.week_start = week_start;
config.daily_title_format = daily_title_format;
@@ -1108,6 +1127,7 @@ pub fn set_general_settings(
config.title_mode = title_mode;
config.hide_title_in_body = hide_title_in_body;
config.show_line_numbers = show_line_numbers;
config.show_link_arrows = show_link_arrows;
config.default_view_mode = default_view_mode;
config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray;
+1
View File
@@ -106,6 +106,7 @@ pub fn run() {
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
commands::set_content_width,
commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook,
+26 -11
View File
@@ -339,14 +339,29 @@ impl WebdavClient {
Ok(())
}
fn put_with_dirs(
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
/// to keep it consistent with future listings. (issue #147)
fn etag_for(&self, relpath: &str) -> String {
self.propfind(relpath, "0")
.ok()
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
.unwrap_or_default()
}
/// PUT a file (creating parent collections first), then read back its authoritative
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
fn upload(
&self,
relpath: &str,
bytes: &[u8],
created: &mut HashSet<String>,
) -> Result<Option<String>, String> {
) -> Result<String, String> {
self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes)
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
Ok(self.etag_for(relpath))
}
}
@@ -511,12 +526,12 @@ fn apply_changes(
);
} else if local_changed && !remote_changed {
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -556,12 +571,12 @@ fn apply_changes(
);
let ckey = conflict_relpath(key, &stamp);
write_local(vault_path, &ckey, &lbytes)?;
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
new_m.files.insert(
ckey,
ManifestEntry {
local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(),
remote_etag: cetag,
},
);
sum.conflicts += 1;
@@ -572,12 +587,12 @@ fn apply_changes(
None => {
// new local -> upload
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -586,12 +601,12 @@ fn apply_changes(
if me.local_hash != lf.hash {
// remote deleted but local edited -> resurrect (upload)
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
remote_etag: etag,
},
);
sum.uploaded += 1;
+27
View File
@@ -72,6 +72,8 @@ pub struct AppConfig {
#[serde(default)]
pub ui_scale: Option<f64>,
#[serde(default)]
pub content_width: Option<u32>,
#[serde(default)]
pub compact_notes: bool,
#[serde(default = "default_true")]
pub show_note_dates: bool,
@@ -95,6 +97,18 @@ pub struct AppConfig {
pub hide_title_in_body: bool,
#[serde(default)]
pub show_line_numbers: bool,
#[serde(default = "default_true")]
pub show_link_arrows: bool,
#[serde(default = "default_true")]
pub show_all_notes: bool,
#[serde(default = "default_true")]
pub show_quick_access: bool,
#[serde(default = "default_true")]
pub show_tasks: bool,
#[serde(default = "default_true")]
pub show_daily_notes: bool,
#[serde(default = "default_true")]
pub show_trash: bool,
#[serde(default)]
pub backup_enabled: bool,
#[serde(default = "default_backup_frequency")]
@@ -203,6 +217,7 @@ impl Default for AppConfig {
font_family: None,
line_height: None,
ui_scale: None,
content_width: None,
compact_notes: false,
show_note_dates: true,
time_format: "relative".to_string(),
@@ -215,6 +230,12 @@ impl Default for AppConfig {
title_mode: "input".to_string(),
hide_title_in_body: false,
show_line_numbers: false,
show_link_arrows: true,
show_all_notes: true,
show_quick_access: true,
show_tasks: true,
show_daily_notes: true,
show_trash: true,
backup_enabled: false,
backup_frequency: "24h".to_string(),
backup_max_count: 10,
@@ -255,12 +276,16 @@ pub struct VaultState {
pub notelist_width: f64,
pub sidebar_collapsed: bool,
#[serde(default)]
pub notelist_collapsed: bool,
#[serde(default)]
pub collapsed_notebooks: Vec<String>,
#[serde(default)]
pub notebook_sort_mode: String,
#[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub note_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub sort_mode: String,
#[serde(default)]
pub last_view_mode: String,
@@ -285,9 +310,11 @@ impl Default for VaultState {
sidebar_width: 220.0,
notelist_width: 280.0,
sidebar_collapsed: false,
notelist_collapsed: false,
collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(),
note_order: std::collections::HashMap::new(),
sort_mode: String::new(),
last_view_mode: String::new(),
last_notebook: None,
+20 -1
View File
@@ -386,7 +386,26 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
.to_string_lossy()
.to_string();
let (meta, content) = frontmatter::parse_note(&raw, &filename);
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Fall back to filesystem times when the frontmatter has no created/modified, matching the
// note-list scan (read_note_entry_from_str). Without this, a missing `created:` defaults to
// Utc::now() here and then gets written to disk on the next save (e.g. a title change),
// resetting the create date of imported/frontmatter-less notes. (issue #139)
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
if let Ok(fs_meta) = fs::metadata(p) {
if !has_modified {
if let Ok(modified) = fs_meta.modified() {
meta.modified = modified.into();
}
}
if !has_created {
if let Ok(created) = fs_meta.created() {
meta.created = created.into();
}
}
}
Ok(NoteContent {
path: path.to_string(),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.1",
"version": "1.3.2",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+25
View File
@@ -961,6 +961,31 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.8);
}
:root[data-theme="one-dark"] {
--bg-primary: #282c34;
--bg-secondary: #21252b;
--bg-tertiary: #2c313a;
--bg-hover: #3e4451;
--bg-active: #4b5263;
--bg-editor: #282c34;
--text-primary: #abb2bf;
--text-secondary: #828997;
--text-tertiary: #5c6370;
--text-accent: #61afef;
--border-color: #3e4451;
--border-light: #2c313a;
--accent: #61afef;
--accent-hover: #4d9fdf;
--accent-light: color-mix(in srgb, #61afef 12%, transparent);
--danger: #e06c75;
--danger-hover: #cc5560;
--success: #98c379;
--warning: #e5c07b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
*,
*::before,
*::after {
+16
View File
@@ -52,6 +52,10 @@ export async function setUiScale(scale: number): Promise<void> {
return invoke("set_ui_scale", { scale });
}
export async function setContentWidth(width: number | null): Promise<void> {
return invoke("set_content_width", { width });
}
export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks");
}
@@ -228,12 +232,18 @@ export async function setGeneralSettings(
titleMode: string,
hideTitleInBody: boolean,
showLineNumbers: boolean,
showLinkArrows: boolean,
defaultViewMode: boolean,
showTrayIcon: boolean,
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
restoreLastSession: boolean,
showAllNotes: boolean,
showQuickAccess: boolean,
showTasks: boolean,
showDailyNotes: boolean,
showTrash: boolean,
): Promise<void> {
return invoke("set_general_settings", {
compactNotes,
@@ -247,12 +257,18 @@ export async function setGeneralSettings(
titleMode,
hideTitleInBody,
showLineNumbers,
showLinkArrows,
defaultViewMode,
showTrayIcon,
closeToTray,
enableWikiLinks,
showNoteDates,
restoreLastSession,
showAllNotes,
showQuickAccess,
showTasks,
showDailyNotes,
showTrash,
});
}
+164 -85
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import Sidebar from './Sidebar.svelte';
import NoteList from './NoteList.svelte';
@@ -15,6 +15,7 @@
sidebarWidth,
notelistWidth,
sidebarCollapsed,
notelistCollapsed,
collapsedNotebooks,
showSearch,
showCommandPalette,
@@ -48,15 +49,18 @@
viewerNote,
notebookSortMode,
notebookOrder,
noteOrder,
syncState,
platformIsMobile
} from '$lib/stores/app';
import { keybindings, matchAction } from '$lib/keybindings';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { darkThemes } from '$lib/platform';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store';
@@ -199,9 +203,11 @@
sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
note_order: $noteOrder,
sort_mode: $sortMode,
last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null,
@@ -226,6 +232,19 @@
persistState();
}
function revealNoteList() {
$notelistCollapsed = false;
tick().then(() => noteList?.refresh());
}
function toggleNoteList() {
const nextCollapsed = !$notelistCollapsed;
$notelistCollapsed = nextCollapsed;
if (!nextCollapsed) {
tick().then(() => noteList?.refresh());
}
}
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false);
@@ -239,7 +258,14 @@
function handleViewChanged() {
taskNoteOpened = false;
noteList?.refresh();
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
if ($notelistCollapsed) {
revealNoteList();
} else {
noteList?.refresh();
}
if (isMobile) $mobileView = 'notelist';
}
@@ -360,6 +386,16 @@
navFromPopstate = false;
return;
}
// If a modal is open, close it instead of navigating
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
$showSettings = false;
$showInfo = false;
$showSearch = false;
$showCommandPalette = false;
// Re-push the current state so the next back still works
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
return;
}
const state = e.state;
const targetDepth = state?.depth ?? 0;
historyDepth = targetDepth;
@@ -383,88 +419,76 @@
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
navigateHistory(-1);
return;
}
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
navigateHistory(1);
return;
}
if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault();
createAndFocusNote();
return;
}
if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault();
$showSearch = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault();
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
}
if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault();
$showCommandPalette = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault();
editor?.forceSave();
return;
}
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && code === 'KeyM') {
const action = matchAction(e, $keybindings);
if (action) {
e.preventDefault();
$sourceMode = !$sourceMode;
}
if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault();
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
switch (action) {
case 'nav-back':
navigateHistory(-1);
return;
case 'nav-forward':
navigateHistory(1);
return;
case 'new-note':
createAndFocusNote();
return;
case 'toggle-theme': {
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
case 'search-vault':
$showSearch = true;
return;
case 'find-in-note':
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
case 'quick-open':
$showCommandPalette = true;
return;
case 'save':
editor?.forceSave();
return;
case 'toggle-source':
$sourceMode = !$sourceMode;
return;
case 'open-new-window':
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
return;
case 'toggle-sidebar':
$sidebarCollapsed = !$sidebarCollapsed;
return;
case 'toggle-notelist':
toggleNoteList();
return;
case 'toggle-focus':
$focusMode = !$focusMode;
return;
case 'toggle-readonly':
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly;
return;
case 'fullscreen':
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
}
if (mod && e.key === '\\') {
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') {
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
@@ -475,8 +499,7 @@
}
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'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
@@ -502,12 +525,23 @@
persistState();
});
$effect(() => {
$sidebarCollapsed;
$notelistCollapsed;
persistState();
});
$effect(() => {
$notebookSortMode;
$notebookOrder;
persistState();
});
$effect(() => {
$noteOrder;
persistState();
});
$effect(() => {
$sortMode;
$tasksLayout;
@@ -534,10 +568,12 @@
$sidebarWidth = state.sidebar_width;
$notelistWidth = state.notelist_width;
$sidebarCollapsed = state.sidebar_collapsed;
$notelistCollapsed = state.notelist_collapsed ?? false;
$collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
$noteOrder = state.note_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
@@ -895,11 +931,24 @@
<ResizeHandle onResize={handleSidebarResize} />
{/if}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
{#if !$notelistCollapsed}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
<ResizeHandle onResize={handleNotelistResize} />
{:else}
<div class="notelist-restore-panel">
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="20" y2="6" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="18" x2="20" y2="18" />
<polyline points="5 8 3 12 5 16" />
</svg>
</button>
</div>
{/if}
{/if}
<div class="editor-panel">
@@ -918,7 +967,7 @@
{/if}
<SearchPanel />
<CommandPalette />
<CommandPalette onNavigate={handleViewChanged} />
<SettingsPanel />
<InfoPanel />
@@ -949,6 +998,36 @@
overflow: hidden;
}
.notelist-restore-panel {
flex-shrink: 0;
width: 36px;
height: 100%;
padding-top: 8px;
display: flex;
justify-content: center;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
}
.notelist-restore-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.notelist-restore-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.editor-panel {
flex: 1;
height: 100%;
+40 -3
View File
@@ -1,6 +1,19 @@
<script lang="ts">
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
import { setTheme, reindex } from '$lib/api';
import { darkThemes } from '$lib/platform';
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
$viewMode = mode;
$activeNotebook = null;
$activeTag = null;
$showCommandPalette = false;
onNavigate();
}
interface Command {
id: string;
@@ -25,6 +38,31 @@
$showSearch = true;
}
},
{
id: 'open-all-notes',
label: 'Open All Notes',
action: () => openView('all')
},
{
id: 'open-quick-access',
label: 'Open Quick Access',
action: () => openView('quickaccess')
},
{
id: 'open-tasks',
label: 'Open Tasks',
action: () => openView('tasks')
},
{
id: 'open-daily',
label: 'Open Daily Notes',
action: () => openView('daily')
},
{
id: 'open-trash',
label: 'Open Trash (restore deleted notes)',
action: () => openView('trash')
},
{
id: 'theme-light',
label: 'Switch to Light Theme',
@@ -142,8 +180,7 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', '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'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
+414 -72
View File
@@ -23,6 +23,7 @@
import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details';
import TextAlign from '@tiptap/extension-text-align';
import { common, createLowlight } from 'lowlight';
import powershell from 'highlight.js/lib/languages/powershell';
import MarkdownIt from 'markdown-it';
import markdownItMark from 'markdown-it-mark';
import markdownItSup from 'markdown-it-sup';
@@ -276,8 +277,8 @@
if (!q || q.startsWith('http://') || q.startsWith('https://') || q.startsWith('mailto:')) return [];
return linkSuggestTitles.filter(e => e.title.toLowerCase().includes(q)).slice(0, 8);
});
let textContextMenu = $state<{ x: number; y: number } | null>(null);
let tableContextMenu = $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; hasStyling: boolean } | null>(null);
let tablePickerOpen = $state(false);
let tablePickerHover = $state({ rows: 0, cols: 0 });
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
@@ -498,6 +499,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
const lowlight = createLowlight(common);
lowlight.register('powershell', powershell);
const codeLanguages = [...lowlight.listLanguages(), 'mermaid'].sort();
const mdit = MarkdownIt({ html: true, linkify: false, breaks: false })
.use(markdownItMark)
@@ -1211,10 +1213,20 @@
});
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) {
const rect = triggerEl.getBoundingClientRect();
codeLangSearch = '';
codeLangDropdown = { pos, x: rect.right, y: rect.bottom + 4, current };
tick().then(() => codeLangInput?.focus());
}
function selectCodeLang(lang: string) {
@@ -1325,6 +1337,45 @@
event.preventDefault();
const { state, dispatch } = view;
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
const depth = 1; // top-level blocks in doc
if (resolvedPos.depth < depth) return true;
@@ -1345,7 +1396,7 @@
const insertAt = tr.mapping.map(prevPos);
tr.insert(insertAt, curSlice.content);
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());
} else {
if (parentIndex >= state.doc.childCount - 1) return true;
@@ -1360,7 +1411,7 @@
const insertAt = tr.mapping.map(parentPos);
tr.insert(insertAt, nextSlice.content);
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());
}
return true;
@@ -1422,7 +1473,7 @@
if (!pre) return false;
// Check if click is in the top-right corner (language button area)
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
const pos = view.posAtDOM(pre, 0);
const resolved = view.state.doc.resolve(pos);
@@ -1443,10 +1494,10 @@
// Virtual trigger for dropdown positioning
const triggerEl = document.createElement('div');
triggerEl.getBoundingClientRect = () => ({
top: rect.top + 6, bottom: rect.top + 26,
left: rect.right - 60, right: rect.right - 6,
width: 54, height: 20,
x: rect.right - 60, y: rect.top + 6,
top: rect.top + 5, bottom: rect.top + 25,
left: rect.right - 100, right: rect.right - 34,
width: 66, height: 20,
x: rect.right - 100, y: rect.top + 5,
toJSON() { return this; },
});
openCodeLangDropdown(cbPos + 1, cbNode.attrs.language || '', triggerEl as any);
@@ -2620,6 +2671,61 @@
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 {
switch (node.type.name) {
case 'paragraph': {
@@ -2676,11 +2782,23 @@
case 'pageBreak':
return '<div style="page-break-after: always;"></div>\n';
case 'table': {
// Preserve tables as raw HTML
const tempDiv = document.createElement('div');
const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
tempDiv.appendChild(frag);
return tempDiv.innerHTML + '\n';
// 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 frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
tempDiv.appendChild(frag);
return tempDiv.innerHTML + '\n';
}
return tableToMarkdown(node) + '\n';
}
case 'pdfEmbed': {
const src = node.attrs.src || '';
@@ -2863,11 +2981,63 @@
// Only steal focus when navigating (Enter/Shift+Enter), not while typing
if (focusTextarea) sourceElement.focus();
sourceElement.setSelectionRange(match.from, match.to);
// Scroll the match into view
const linesBefore = sourceContent.substring(0, match.from).split('\n').length;
scrollSourceToOffset(match.from);
}
// 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 targetScroll = (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2;
sourceElement.scrollTop = Math.max(0, targetScroll);
sourceElement.scrollTop = Math.max(0, (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2);
}
// ── 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) {
@@ -3213,6 +3383,21 @@
// Pre-process: fix image paths with multiple leading slashes (from broken saves)
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
src = src.replace(/!\[([^\]]*)\]\(([^)]*\s[^)]*)\)/g, (match, alt, url) => {
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">&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
// 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>
@@ -3331,7 +3521,7 @@
if (pendingContent !== null) {
createEditor(pendingContent);
pendingContent = null;
} else if (isMobile) {
} else if (isMobile && !lastSourceMode) {
createEditor('');
}
}
@@ -3392,7 +3582,12 @@
function createEditor(content: string) {
if (!editorElement) return;
destroyEditor();
if (editor) {
editor.destroy();
editor = null;
}
mathObserver?.disconnect();
mathObserver = null;
isLargeDoc = content.length > LARGE_DOC_CHARS;
const html = markdownToHtml(content);
@@ -3929,12 +4124,28 @@
let x = event.clientX;
let y = event.clientY;
const menuWidth = 220;
const menuHeight = 520;
const menuHeight = 600;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 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;
}
// 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 (x < 4) x = 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() {
@@ -4615,27 +4828,53 @@
if (!loadedPath) return;
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 ?? '');
resetSourceHistory(sourceContent);
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) {
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) {
// Mobile: editor stays in DOM, just update its content
const content = sourceContent || ($activeNote?.content ?? '');
const content = srcText || ($activeNote?.content ?? '');
if (editor) {
ignoreNextUpdate = true;
editor.commands.setContent(markdownToHtml(content));
tick().then(restoreRichCaret);
}
} else {
// Desktop: destroy old editor (its DOM element is gone),
// wait for DOM to swap textarea→div, then create editor on new element.
destroyEditor();
const content = sourceContent || ($activeNote?.content ?? '');
const content = srcText || ($activeNote?.content ?? '');
tick().then(() => {
if (editorElement && !editor) {
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>
File
</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(); }}>
<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(); }}>
<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(); }}>
<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; 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>
{/if}
</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>
</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>
<!-- 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>
File
</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(); }}>
<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(); }}>
<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(); }}>
<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(); }}>
<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'); }}>
<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
@@ -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>
</button>
{#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: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</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 -->
<div class="table-ctx-overlay" onclick={closeTableContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px" 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}>
<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
@@ -5932,6 +6210,13 @@
{/each}
</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}>
<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
@@ -6146,18 +6431,27 @@
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
<button
class="code-lang-option"
class:active={codeLangDropdown.current === ''}
onclick={() => selectCodeLang('')}
>auto</button>
{#each codeLanguages as lang}
<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
class="code-lang-option"
class:active={codeLangDropdown.current === ''}
onclick={() => selectCodeLang('')}
>auto</button>
{/if}
{#each codeLangFiltered as lang}
<button
class="code-lang-option"
class:active={codeLangDropdown.current === lang}
onclick={() => selectCodeLang(lang)}
>{lang}</button>
{/each}
{#if codeLangFiltered.length === 0}
<div class="code-lang-empty">No match</div>
{/if}
</div>
</div>
{/if}
@@ -7178,7 +7472,8 @@
resize: none;
outline: none;
padding: 0 0 var(--editor-scroll-past-end, 65vh);
margin: 0;
margin: 0 auto;
max-width: var(--editor-content-width, none);
user-select: text;
/* Wrap long lines instead of horizontal-scrolling (matches mobile). (issue #100) */
white-space: pre-wrap;
@@ -7192,6 +7487,9 @@
Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */
white-space: pre;
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 {
@@ -7224,6 +7522,12 @@
.tiptap-wrapper {
height: 100%;
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) {
@@ -7311,15 +7615,15 @@
:global(.tiptap-wrapper .tiptap .code-copy-btn) {
position: absolute;
top: 6px;
right: 76px;
padding: 3px 6px;
border-radius: 5px;
top: 5px;
right: 5px;
padding: 2px 6px;
border-radius: 4px;
background: transparent;
border: 1px solid transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0.35;
opacity: 0.4;
z-index: 2;
display: flex;
align-items: center;
@@ -7349,8 +7653,8 @@
:global(.tiptap-wrapper .tiptap pre)::after {
content: attr(data-language);
position: absolute;
top: 6px;
right: 6px;
top: 5px;
right: 34px;
padding: 2px 6px;
border-radius: 4px;
background: transparent;
@@ -7358,9 +7662,10 @@
font-size: 11px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
cursor: pointer;
opacity: 0;
opacity: 0.4;
pointer-events: none;
z-index: 1;
transition: opacity 0.15s;
}
:global(.tiptap-wrapper .tiptap pre[data-language=""])::after {
@@ -7368,7 +7673,7 @@
}
:global(.tiptap-wrapper .tiptap pre:hover)::after {
opacity: 0.7;
opacity: 1;
}
:global(.tiptap-wrapper .tiptap .secret-block) {
@@ -7905,12 +8210,36 @@
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 4px;
max-height: 280px;
max-height: 320px;
overflow-y: auto;
min-width: 130px;
min-width: 150px;
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 {
width: 5px;
}
@@ -8154,6 +8483,14 @@
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) {
content: '⤴';
}
@@ -8608,8 +8945,6 @@
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 200px;
max-height: calc(100vh - 16px);
overflow-y: auto;
z-index: 1501;
}
@@ -8687,6 +9022,13 @@
z-index: 1502;
}
.text-ctx-submenu.flip-left {
left: auto;
right: 100%;
margin-left: 0;
margin-right: 2px;
}
.text-ctx-submenu button {
display: flex;
align-items: center;
+149 -19
View File
@@ -5,10 +5,85 @@
import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
import {
ACTIONS,
keybindings,
setBinding,
resetBinding,
bindingFromEvent,
bindingToKeys,
bindingsEqual,
type ActionDef,
} from '$lib/keybindings';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Customizable shortcuts, grouped for display.
const actionGroups: { title: string; actions: ActionDef[] }[] = [
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
];
// id of the action currently listening for a new key combo, or null.
let capturingId = $state<string | null>(null);
// id of an action whose row should briefly flash a "already in use" warning.
let conflictId = $state<string | null>(null);
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
function startCapture(id: string) {
conflictId = null;
capturingId = id;
}
function stopCapture() {
capturingId = null;
}
function flashConflict(id: string) {
conflictId = id;
if (conflictTimer) clearTimeout(conflictTimer);
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
}
function captureKeydown(e: KeyboardEvent) {
if (!capturingId) return;
// Capture phase + stopImmediatePropagation keeps the combo from also
// triggering the app-level shortcut handler on window.
e.preventDefault();
e.stopImmediatePropagation();
if (e.key === 'Escape') { stopCapture(); return; }
const binding = bindingFromEvent(e);
if (!binding) return; // modifier-only press; keep listening
// Reject combos already bound to a different action.
const map = $keybindings;
const target = capturingId;
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
if (clash) {
flashConflict(target);
stopCapture();
return;
}
setBinding(target, binding);
stopCapture();
}
$effect(() => {
if (!capturingId) return;
window.addEventListener('keydown', captureKeydown, true);
return () => window.removeEventListener('keydown', captureKeydown, true);
});
function resetKey(e: MouseEvent, id: string) {
e.preventDefault();
resetBinding(id);
if (capturingId === id) stopCapture();
}
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...');
@@ -158,27 +233,39 @@
</div>
{:else}
<div class="shortcuts-section">
<h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
{#if !isMobile}
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
{/if}
{#each actionGroups as group}
<h4 class="shortcuts-group-title">{group.title}</h4>
{#each group.actions as action}
<div class="shortcut-row">
<span class="shortcut-desc">{action.label}</span>
{#if isMobile}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{:else}
<button
class="shortcut-key-btn"
class:capturing={capturingId === action.id}
class:conflict={conflictId === action.id}
title="Click to rebind · right-click to reset"
onclick={() => startCapture(action.id)}
oncontextmenu={(e) => resetKey(e, action.id)}
>
{#if capturingId === action.id}
<span class="capture-prompt">Press keys…</span>
{:else if conflictId === action.id}
<span class="conflict-msg">Already in use</span>
{:else}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{/if}
</button>
{/if}
</div>
{/each}
{/each}
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
@@ -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">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
@@ -501,6 +589,48 @@
color: var(--text-tertiary);
}
.shortcuts-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 4px;
}
.shortcut-key-btn {
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 4px;
margin: -2px -4px;
cursor: pointer;
display: flex;
align-items: center;
flex-shrink: 0;
font: inherit;
}
.shortcut-key-btn:hover {
background: var(--bg-hover);
}
.shortcut-key-btn.capturing {
border-color: var(--accent);
background: var(--accent-light);
}
.shortcut-key-btn.conflict {
border-color: var(--danger, #e11d48);
}
.capture-prompt {
font-size: 12px;
color: var(--accent);
}
.conflict-msg {
font-size: 12px;
color: var(--danger, #e11d48);
}
.shortcut-keys kbd,
.shortcut-desc kbd {
display: inline-block;
+123 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import {
notes,
sortedNotes,
@@ -11,6 +12,8 @@
editorDirty,
quickAccessPaths,
appConfig,
notelistCollapsed,
noteOrder,
notebooks,
tags,
mobileView
@@ -143,6 +146,7 @@
let sortMenu = $state<{ x: number; y: number } | null>(null);
let editingNote = $state<string | null>(null);
let editValue = $state('');
let renameInput: HTMLInputElement | null = $state(null);
let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null);
let tagEditTags = $state<string[]>([]);
@@ -203,6 +207,11 @@
let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
let noteDragFrom = $state<number | null>(null);
let noteDragOver = $state<number | null>(null);
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
const customOrderViews = new Set(['all', 'notebook', 'tag']);
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
// Virtual scroll
let listContainer = $state<HTMLDivElement>(null!);
@@ -412,6 +421,7 @@
}
try {
const entry = await createNote(nbRelative, 'Untitled');
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear();
await refresh();
await selectNote(entry);
@@ -429,6 +439,7 @@
try {
const newTitle = editValue.trim();
const newPath = await renameNote(note.path, newTitle);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear();
editingNote = null;
// Update local store immediately (avoid full vault re-scan)
@@ -450,6 +461,7 @@
contextMenu = null;
try {
await deleteNote(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear();
// Remove from local store immediately (avoid full vault re-scan)
$notes = $notes.filter(n => n.path !== note.path);
@@ -477,6 +489,7 @@
contextMenu = null;
try {
await permanentDelete(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) {
@@ -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) {
contextMenu = null;
movePickerNote = null;
try {
const newPath = await moveNote(note.path, destPath);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) {
@@ -701,10 +763,13 @@
contextMenu = { x, y, note };
}
function startRename(note: NoteEntry) {
async function startRename(note: NoteEntry) {
contextMenu = null;
editingNote = note.path;
editValue = note.meta.title;
await tick();
renameInput?.focus();
renameInput?.select();
}
function getNotebookPath(note: NoteEntry): string {
@@ -773,6 +838,7 @@
async function handleBatchDelete() {
const toDelete = new Set(selectedPaths);
noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null;
@@ -808,6 +874,7 @@
async function handleBatchPermanentDelete() {
const toDelete = new Set(selectedPaths);
noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null;
@@ -833,6 +900,9 @@
}
function setSortMode(mode: SortMode) {
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
persistManualNoteOrder($sortedNotes);
}
$sortMode = mode;
sortMenu = null;
}
@@ -856,6 +926,16 @@
<div class="list-header">
<span class="list-title">{viewTitle}</span>
<div class="list-actions">
{#if !isMobile}
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="16" y2="6" />
<line x1="4" y1="12" x2="16" y2="12" />
<line x1="4" y1="18" x2="16" y2="18" />
<polyline points="19 8 21 12 19 16" />
</svg>
</button>
{/if}
{#if $viewMode !== 'tasks'}
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
@@ -1055,6 +1135,7 @@
<input
type="text"
class="rename-input"
bind:this={renameInput}
bind:value={editValue}
onkeydown={(e) => {
if (e.key === 'Enter') handleRename(note);
@@ -1072,7 +1153,15 @@
class:compact={compact}
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(note);
}
}}
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
@@ -1099,6 +1188,12 @@
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (canCustomReorder) {
noteDragFrom = noteIndex;
e.dataTransfer!.setData('text/plain', note.path);
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else {
@@ -1113,18 +1208,28 @@
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
qaDragOver = noteIndex;
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
noteDragOver = noteIndex;
}
}}
ondragleave={() => {
if (qaDragOver === noteIndex) qaDragOver = null;
if (noteDragOver === noteIndex) noteDragOver = null;
}}
ondrop={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault();
handleQaDrop(noteIndex);
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
handleCustomNoteDrop(noteIndex);
}
}}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
>
{#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1403,6 +1508,18 @@
Title
{#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if}
</button>
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="4" cy="6" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="4" cy="18" r="1" />
</svg>
Custom
{#if $sortMode === 'custom'}<span class="sort-check">&#10003;</span>{/if}
</button>
</div>
{/if}
@@ -1925,11 +2042,13 @@
font-size: 14px;
}
.note-item.qa-drag-above {
.note-item.qa-drag-above,
.note-item.note-drag-above {
border-top: 2px solid var(--accent);
}
.note-item.qa-drag-below {
.note-item.qa-drag-below,
.note-item.note-drag-below {
border-bottom: 2px solid var(--accent);
}
+7 -7
View File
@@ -12,10 +12,13 @@
theme
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
import type { FileEvent } from '$lib/types';
let { notePath }: { notePath: string } = $props();
import { darkThemes } from '$lib/platform';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!);
@@ -35,8 +38,7 @@
$effect(() => {
const t = $theme || 'system';
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', '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'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
@@ -72,13 +74,11 @@
}
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && e.code === 'KeyS') {
const action = matchAction(e, $keybindings);
if (action === 'save') {
e.preventDefault();
editor?.forceSave();
return;
}
if (mod && e.shiftKey && e.code === 'KeyM') {
} else if (action === 'toggle-source') {
e.preventDefault();
$sourceMode = !$sourceMode;
}
+125 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, 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 { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app';
@@ -9,6 +10,7 @@
import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
let activeTab = $state<Tab>('styling');
@@ -384,7 +386,6 @@
}
}
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', '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(
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: 'cyberpunk', label: 'Cyberpunk', bg: '#0a0e17', sidebar: '#060911', accent: '#ff0080' },
{ id: 'black', label: 'Black', bg: '#000000', sidebar: '#0a0a0a', accent: '#ffffff' },
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
];
const accentPresets = [
@@ -476,12 +478,20 @@
{ label: '200%', value: 2 },
];
const contentWidthPresets: { label: string; value: number | null }[] = [
{ label: 'Narrow', value: 600 },
{ label: 'Medium', value: 760 },
{ label: 'Wide', value: 960 },
{ label: 'Full', value: null },
];
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14);
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null);
let themeDropdownOpen = $state(false);
let accentDropdownOpen = $state(false);
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
@@ -507,11 +517,19 @@
let titleMode = $state($appConfig?.title_mode ?? 'input');
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
// Sidebar item visibility
let showAllNotes = $state($appConfig?.show_all_notes ?? true);
let showQuickAccess = $state($appConfig?.show_quick_access ?? true);
let showTasks = $state($appConfig?.show_tasks ?? true);
let showDailyNotes = $state($appConfig?.show_daily_notes ?? true);
let showTrash = $state($appConfig?.show_trash ?? true);
const pdfHeightPresets = [
{ label: 'Small', value: 300 },
{ label: 'Medium', value: 450 },
@@ -564,15 +582,32 @@
$appConfig.title_mode = titleMode;
$appConfig.hide_title_in_body = hideTitleInBody;
$appConfig.show_line_numbers = showLineNumbers;
$appConfig.show_link_arrows = showLinkArrows;
$appConfig.default_view_mode = defaultViewMode;
$appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession)
$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, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
.catch((e) => console.error('Failed to save general settings:', e));
}
function applyLinkArrows() {
document.documentElement.classList.toggle('no-link-arrows', !showLinkArrows);
}
function selectTheme(preset: typeof themePresets[0]) {
$theme = preset.id;
setTheme(preset.id);
themeDropdownOpen = false;
selectCustomAccent(preset.accent);
}
function selectAccent(preset: typeof accentPresets[0]) {
activeAccent = preset.name;
applyAccent(preset);
@@ -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 handle(e: MouseEvent) {
if (!node.contains(e.target as Node)) callback();
@@ -718,6 +764,11 @@
activeLineHeight = savedLineHeight;
applyLineHeight(savedLineHeight);
}
const savedContentWidth = $appConfig?.content_width;
if (savedContentWidth) {
activeContentWidth = savedContentWidth;
applyContentWidth(savedContentWidth);
}
// Sync general settings
if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false;
@@ -731,6 +782,11 @@
pdfPreview = $appConfig.pdf_preview ?? false;
pdfHeight = $appConfig.pdf_height ?? 600;
titleMode = $appConfig.title_mode ?? 'input';
showAllNotes = $appConfig.show_all_notes ?? true;
showQuickAccess = $appConfig.show_quick_access ?? true;
showTasks = $appConfig.show_tasks ?? true;
showDailyNotes = $appConfig.show_daily_notes ?? true;
showTrash = $appConfig.show_trash ?? true;
}
});
@@ -854,6 +910,43 @@
</div>
</div>
{#if !isMobile}
<div class="settings-section">
<h3>Sidebar</h3>
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">All Notes</span></span>
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Tasks</span></span>
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label"><span class="setting-name">Trash</span></span>
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
{/if}
<div class="settings-section">
<h3>Time Format</h3>
<div class="setting-options">
@@ -956,6 +1049,15 @@
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Show link arrows</span>
<span class="setting-desc">Display the arrow icon after external links</span>
</span>
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
<span class="toggle-knob"></span>
</button>
</label>
{#if !isMobile}
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
@@ -1061,7 +1163,7 @@
<button
class="dropdown-item"
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="preview-sidebar"></span>
@@ -1162,6 +1264,24 @@
</div>
{/if}
{#if !isMobile}
<div class="settings-section">
<h3>Note Width</h3>
<div class="font-size-options">
{#each contentWidthPresets as preset}
<button
class="font-size-btn"
class:active={activeContentWidth === preset.value}
onclick={() => selectContentWidth(preset.value)}
>
<span class="font-size-label">{preset.label}</span>
</button>
{/each}
</div>
<p class="setting-hint">Caps the width of the note text for easier reading on wide screens; the column stays centered. Most noticeable in Focus Mode. "Full" uses the entire width.</p>
</div>
{/if}
<div class="settings-section">
<h3>Line Height</h3>
<div class="font-size-options">
+166 -28
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import {
notebooks,
notes,
@@ -20,7 +21,7 @@
notebookSortMode,
notebookOrder
} from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core';
@@ -33,10 +34,22 @@
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
const anyNavItem = $derived(
$appConfig?.show_all_notes !== false ||
$appConfig?.show_quick_access !== false ||
$appConfig?.show_tasks !== false ||
$appConfig?.show_daily_notes !== false ||
$appConfig?.show_trash !== false
);
let editingNotebook = $state<string | null>(null);
let editValue = $state('');
let renameInput: HTMLInputElement | null = $state(null);
let newNotebookName = $state('');
let showNewNotebook = $state(false);
let newNotebookInput: HTMLInputElement | null = $state(null);
let dropTargetPath = $state<string | null>(null);
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
let draggedNotebookPath = $state<string | null>(null);
@@ -59,19 +72,25 @@
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null);
function toggleCollapse(path: string, e: MouseEvent) {
function expandNotebook(nb: NotebookEntry) {
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
}
}
function toggleCollapse(nb: NotebookEntry, e: Event) {
e.stopPropagation();
if ($collapsedNotebooks.includes(path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== path);
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
} else {
$collapsedNotebooks = [...$collapsedNotebooks, path];
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
}
}
// Collapse-all / expand-all: toggles every notebook that has children.
function collectParentPaths(tree: NotebookEntry[]): string[] {
const out: string[] = [];
for (const nb of tree) {
if (nb.children && nb.children.length > 0) {
if (nb.children.length > 0) {
out.push(nb.path);
out.push(...collectParentPaths(nb.children));
}
@@ -133,12 +152,23 @@
onViewChanged();
}
function selectNotebook(nb: NotebookEntry) {
async function selectNotebook(nb: NotebookEntry) {
const hasVisibleChildren = nb.children.length > 0;
const wasCollapsed = $collapsedNotebooks.includes(nb.path);
// Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
if (hasVisibleChildren) {
if (wasCollapsed) {
await expandNotebook(nb);
} else {
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
}
return;
}
selectAllNotes();
return;
}
if (hasVisibleChildren) await expandNotebook(nb);
$viewMode = 'notebook';
$activeNotebook = nb;
$activeTag = null;
@@ -182,11 +212,52 @@
let newNotebookParent = $state<NotebookEntry | null>(null);
function getNotebookNameSegments(name: string): string[] | null {
const segments = name.split('/').map((segment) => segment.trim());
if (segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) {
return null;
}
return segments;
}
async function focusNewNotebookInput() {
await tick();
newNotebookInput?.focus();
}
async function toggleNewNotebookInput() {
if (showNewNotebook) {
showNewNotebook = false;
newNotebookName = '';
newNotebookParent = null;
return;
}
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
await focusNewNotebookInput();
}
async function startNewNotebookFromSidebar(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .context-menu, .delete-confirm-overlay')) return;
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
newNotebookName = '';
showNewNotebook = true;
await focusNewNotebookInput();
}
async function handleCreateNotebook() {
if (!newNotebookName.trim()) return;
try {
const trimmedName = newNotebookName.trim();
const parentRel = newNotebookParent?.relative_path ?? null;
await createNotebook(parentRel, newNotebookName.trim());
const name = parentRel ? trimmedName : getNotebookNameSegments(trimmedName)?.join('/');
if (!name) return;
await createNotebook(parentRel, name);
newNotebookName = '';
showNewNotebook = false;
newNotebookParent = null;
@@ -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;
newNotebookParent = nb;
newNotebookName = '';
@@ -205,6 +293,7 @@
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
}
await focusNewNotebookInput();
}
async function handleRename(nb: NotebookEntry) {
@@ -394,10 +483,13 @@
}
}
function startRename(nb: NotebookEntry) {
async function startRename(nb: NotebookEntry) {
contextMenu = null;
editingNotebook = nb.path;
editValue = nb.name;
await tick();
renameInput?.focus();
renameInput?.select();
}
async function handleSetIcon(nb: NotebookEntry) {
@@ -486,7 +578,7 @@
<svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}>
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem} ondblclick={startNewNotebookFromSidebar}>
{#if !isMobile}
<div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
@@ -498,19 +590,19 @@
{/if}
</svg>
</button>
{#if !$sidebarCollapsed}
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
{/if}
</div>
{/if}
{#if !$sidebarCollapsed}
{#if anyNavItem}
<nav class="sidebar-nav">
{#if $appConfig?.show_all_notes !== false}
<button
class="nav-item"
class:active={$viewMode === 'all'}
@@ -522,7 +614,9 @@
</svg>
<span>All Notes</span>
</button>
{/if}
{#if $appConfig?.show_quick_access !== false}
<button
class="nav-item"
class:active={$viewMode === 'quickaccess'}
@@ -533,7 +627,9 @@
</svg>
<span>Quick Access</span>
</button>
{/if}
{#if $appConfig?.show_tasks !== false}
<button
class="nav-item"
class:active={$viewMode === 'tasks'}
@@ -545,7 +641,9 @@
</svg>
<span>Tasks</span>
</button>
{/if}
{#if $appConfig?.show_daily_notes !== false}
<button
class="nav-item"
class:active={$viewMode === 'daily'}
@@ -559,7 +657,9 @@
</svg>
<span>Daily Notes</span>
</button>
{/if}
{#if $appConfig?.show_trash !== false}
<button
class="nav-item"
class:active={$viewMode === 'trash'}
@@ -571,7 +671,9 @@
</svg>
<span>Trash</span>
</button>
{/if}
</nav>
{/if}
<div class="section">
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -606,15 +708,7 @@
</svg>
</button>
{/if}
<button class="icon-btn-sm" onclick={() => {
if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else {
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
}
}} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<button class="icon-btn-sm" onclick={toggleNewNotebookInput} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -630,13 +724,11 @@
</button>
{/if}
<input
bind:this={newNotebookInput}
type="text"
bind:value={newNotebookName}
placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
onkeydown={(e) => {
if (e.key === 'Enter') handleCreateNotebook();
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; }
}}
onkeydown={handleNewNotebookKeydown}
/>
</div>
{/if}
@@ -708,6 +800,7 @@
{/if}
</div>
{/if}
{/if}
<div class="sidebar-footer">
<button class="icon-btn" onclick={() => ($showInfo = true)} title="Info">
@@ -727,7 +820,6 @@
</div>
</div>
{/if}
</aside>
{#if contextMenu}
@@ -794,6 +886,7 @@
<input
type="text"
class="rename-input"
bind:this={renameInput}
bind:value={editValue}
onkeydown={(e) => {
if (e.key === 'Enter') handleRename(nb);
@@ -812,6 +905,12 @@
style="padding-left: {8 + depth * 16}px"
draggable="true"
onclick={() => selectNotebook(nb)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(nb);
}
}}
oncontextmenu={(e) => onContextMenu(e, nb)}
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
@@ -863,7 +962,7 @@
>
{#if hasChildren}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb.path, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb.path, e); }}>
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb, e); }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
@@ -906,6 +1005,29 @@
max-width: 44px;
}
/* Collapsed rail: stack the toolbar icons vertically - search under the expand chevron at
the top, info + settings pinned to the bottom. */
.sidebar.collapsed .sidebar-header {
flex-direction: column;
justify-content: flex-start;
gap: 4px;
padding: 8px 4px;
border-bottom: none;
}
.sidebar.collapsed .sidebar-footer {
flex-direction: column;
align-items: center;
gap: 4px;
margin-top: auto;
padding: 8px 4px;
border-top: none;
}
.sidebar.collapsed .sidebar-footer-right {
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
@@ -949,6 +1071,22 @@
flex: 1;
overflow-y: auto;
padding: 4px 0;
border-top: 1px solid var(--border-light);
}
/* With the top nav hidden, the header's border-bottom is the only divider needed -
drop the section's border-top so there's no doubled line under the header. */
.sidebar.nav-empty .section {
border-top: none;
}
/* Pin the Notebooks header so its collapse/expand-all button stays reachable while the tree
scrolls; the section's border-top draws the divider above it, matching the Tags section. (issue #126) */
.section > .section-header {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-secondary);
}
.tags-section {
+164
View File
@@ -0,0 +1,164 @@
import { writable } from "svelte/store";
// A single key combination. `mod` means Ctrl on Windows/Linux and Cmd on macOS.
// `code` is a KeyboardEvent.code value (layout-independent), e.g. "KeyN",
// "Backslash", "ArrowLeft", "F11".
export interface Binding {
mod?: boolean;
shift?: boolean;
alt?: boolean;
code: string;
}
export interface ActionDef {
id: string;
label: string;
group: "General" | "Interface" | "Navigation";
default: Binding;
}
// Every customizable action and its factory-default binding. These mirror the
// handlers in AppLayout.handleKeydown and the rows in InfoPanel's Shortcuts tab.
export const ACTIONS: ActionDef[] = [
{ id: "new-note", label: "New note", group: "General", default: { mod: true, code: "KeyN" } },
{ id: "save", label: "Save", group: "General", default: { mod: true, code: "KeyS" } },
{ id: "quick-open", label: "Quick open", group: "General", default: { mod: true, code: "KeyP" } },
{ id: "find-in-note", label: "Find in note", group: "General", default: { mod: true, code: "KeyF" } },
{ id: "search-vault", label: "Search vault", group: "General", default: { mod: true, shift: true, code: "KeyF" } },
{ id: "open-new-window", label: "Open note in new window", group: "General", default: { mod: true, shift: true, code: "KeyW" } },
{ id: "toggle-sidebar", label: "Toggle sidebar", group: "Interface", default: { mod: true, code: "Backslash" } },
{ id: "toggle-notelist", label: "Toggle notes list", group: "Interface", default: { mod: true, shift: true, code: "Backslash" } },
{ id: "toggle-theme", label: "Toggle dark / light theme", group: "Interface", default: { mod: true, shift: true, code: "KeyN" } },
{ id: "toggle-focus", label: "Toggle focus mode", group: "Interface", default: { mod: true, shift: true, code: "KeyE" } },
{ id: "toggle-readonly", label: "Toggle read-only", group: "Interface", default: { mod: true, shift: true, code: "KeyR" } },
{ id: "toggle-source", label: "Toggle source / WYSIWYG", group: "Interface", default: { mod: true, shift: true, code: "KeyM" } },
{ id: "fullscreen", label: "Toggle fullscreen", group: "Interface", default: { code: "F11" } },
{ id: "nav-back", label: "Go back", group: "Navigation", default: { alt: true, code: "ArrowLeft" } },
{ id: "nav-forward", label: "Go forward", group: "Navigation", default: { alt: true, code: "ArrowRight" } },
];
export const DEFAULT_BINDINGS: Record<string, Binding> = Object.fromEntries(
ACTIONS.map((a) => [a.id, a.default]),
);
const STORAGE_KEY = "helix-keybindings";
function loadBindings(): Record<string, Binding> {
const merged: Record<string, Binding> = { ...DEFAULT_BINDINGS };
try {
const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY);
if (raw) {
const stored = JSON.parse(raw) as Record<string, Binding>;
for (const id of Object.keys(merged)) {
if (stored[id]) merged[id] = stored[id];
}
}
} catch {
// Corrupt storage - fall back to defaults.
}
return merged;
}
export const keybindings = writable<Record<string, Binding>>(loadBindings());
keybindings.subscribe((map) => {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
}
} catch {
// Ignore quota / unavailable storage.
}
});
export function setBinding(id: string, binding: Binding) {
keybindings.update((m) => ({ ...m, [id]: binding }));
}
export function resetBinding(id: string) {
keybindings.update((m) => ({ ...m, [id]: DEFAULT_BINDINGS[id] }));
}
// True when the keyboard event matches the given binding exactly. Modifier
// state must match precisely so e.g. Mod+Shift+F never also triggers Mod+F.
export function matchBinding(e: KeyboardEvent, b: Binding | undefined): boolean {
if (!b) return false;
const mod = e.ctrlKey || e.metaKey;
return (
!!b.mod === mod &&
!!b.shift === e.shiftKey &&
!!b.alt === e.altKey &&
e.code === b.code
);
}
// Returns the id of the first action whose binding matches the event, or null.
export function matchAction(e: KeyboardEvent, map: Record<string, Binding>): string | null {
for (const id of Object.keys(map)) {
if (matchBinding(e, map[id])) return id;
}
return null;
}
// Build a Binding from a captured keydown event. Returns null for events that
// are only modifier keys (no real key pressed yet).
const MODIFIER_CODES = new Set([
"ControlLeft", "ControlRight", "MetaLeft", "MetaRight",
"ShiftLeft", "ShiftRight", "AltLeft", "AltRight",
]);
export function bindingFromEvent(e: KeyboardEvent): Binding | null {
if (MODIFIER_CODES.has(e.code) || !e.code) return null;
return {
mod: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
alt: e.altKey,
code: e.code,
};
}
export function bindingsEqual(a: Binding, b: Binding): boolean {
return !!a.mod === !!b.mod && !!a.shift === !!b.shift && !!a.alt === !!b.alt && a.code === b.code;
}
const isMac = typeof navigator !== "undefined" && navigator.platform.startsWith("Mac");
const CODE_LABELS: Record<string, string> = {
Backslash: "\\",
BracketLeft: "[",
BracketRight: "]",
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
Backquote: "`",
Minus: "-",
Equal: "=",
Space: "Space",
Enter: "Enter",
Tab: "Tab",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
};
function codeToLabel(code: string): string {
if (code.startsWith("Key")) return code.slice(3);
if (code.startsWith("Digit")) return code.slice(5);
if (code.startsWith("Numpad")) return code.slice(6);
return CODE_LABELS[code] ?? code;
}
// Ordered list of key labels for rendering, e.g. ["Ctrl", "Shift", "N"].
export function bindingToKeys(b: Binding): string[] {
const keys: string[] = [];
if (b.mod) keys.push(isMac ? "⌘" : "Ctrl");
if (b.alt) keys.push(isMac ? "⌥" : "Alt");
if (b.shift) keys.push(isMac ? "⇧" : "Shift");
keys.push(codeToLabel(b.code));
return keys;
}
+34
View File
@@ -5,3 +5,37 @@ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
export const isAndroid = /android/i.test(ua);
export const isIOS = /iphone|ipad|ipod/i.test(ua);
export const isMobile = isAndroid || isIOS;
// Themes that use the dark color scheme. Used by applyTheme() to toggle the
// `dark` class on the root element. Keep this in sync when adding new themes.
export const darkThemes = [
'dark',
'solarized-dark',
'catppuccin',
'nord',
'tokyo-night',
'github-dark',
'dracula',
'blueberry',
'forest-green',
'gruvbox',
'midnight-tide',
'cherry-blossom',
'synthwave',
'ember',
'moonlit',
'dark-coffee',
'crimson',
'material-dark',
'monokai',
'rose-pine',
'everforest',
'horizon',
'cyberpunk',
'black',
'one-dark',
];
export function isDarkTheme(theme: string): boolean {
return darkThemes.includes(theme) || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
+24 -2
View File
@@ -21,6 +21,7 @@ export const tasksHideCompleted = writable<boolean>(true);
export const tasksOnlyFlagged = writable<boolean>(false);
export const tasksSort = writable<"due" | "priority" | "note">("due");
export const sidebarCollapsed = writable(false);
export const notelistCollapsed = writable(false);
export const sidebarWidth = writable(220);
export const notelistWidth = writable(280);
export const searchQuery = writable("");
@@ -39,6 +40,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">(
);
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
export const notebookOrder = writable<Record<string, number>>({});
export const noteOrder = writable<Record<string, number>>({});
// Data
export const notebooks = writable<NotebookEntry[]>([]);
@@ -176,16 +178,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length
// Derived
export const sortedNotes = derived(
[notes, sortMode, viewMode],
([$notes, $sortMode, $viewMode]) => {
[notes, sortMode, viewMode, noteOrder],
([$notes, $sortMode, $viewMode, $noteOrder]) => {
// Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes;
const customSortable =
$viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag";
const pinned = $notes.filter((n) => n.meta.pinned);
const unpinned = $notes.filter((n) => !n.meta.pinned);
const sortFn = (a: NoteEntry, b: NoteEntry) => {
switch ($sortMode) {
case "custom": {
if (!customSortable) {
return (
new Date(b.meta.modified).getTime() -
new Date(a.meta.modified).getTime()
);
}
const oa = $noteOrder[a.path] ?? Number.MAX_SAFE_INTEGER;
const ob = $noteOrder[b.path] ?? Number.MAX_SAFE_INTEGER;
if (oa !== ob) return oa - ob;
return a.meta.title.localeCompare(b.meta.title);
}
case "title":
return a.meta.title.localeCompare(b.meta.title);
case "created":
@@ -212,27 +228,33 @@ export const vaultState = derived(
sidebarWidth,
notelistWidth,
sidebarCollapsed,
notelistCollapsed,
collapsedNotebooks,
notebookSortMode,
notebookOrder,
noteOrder,
],
([
$activeNotePath,
$sidebarWidth,
$notelistWidth,
$sidebarCollapsed,
$notelistCollapsed,
$collapsedNotebooks,
$notebookSortMode,
$notebookOrder,
$noteOrder,
]) => {
return {
last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
note_order: $noteOrder,
} satisfies VaultState;
},
);
+10 -1
View File
@@ -55,6 +55,7 @@ export interface AppConfig {
font_family: string | null;
line_height: number | null;
ui_scale: number | null;
content_width: number | null;
compact_notes: boolean;
show_note_dates: boolean;
time_format: string;
@@ -67,6 +68,12 @@ export interface AppConfig {
title_mode: string;
hide_title_in_body: boolean;
show_line_numbers: boolean;
show_link_arrows: boolean;
show_all_notes: boolean;
show_quick_access: boolean;
show_tasks: boolean;
show_daily_notes: boolean;
show_trash: boolean;
backup_enabled: boolean;
backup_frequency: string;
backup_max_count: number;
@@ -103,9 +110,11 @@ export interface VaultState {
sidebar_width: number;
notelist_width: number;
sidebar_collapsed: boolean;
notelist_collapsed: boolean;
collapsed_notebooks: string[];
notebook_sort_mode?: string;
notebook_order?: Record<string, number>;
note_order?: Record<string, number>;
sort_mode?: string;
last_view_mode?: string;
last_notebook?: string | null;
@@ -164,7 +173,7 @@ export interface NoteTitleEntry {
path: string;
}
export type SortMode = "modified" | "title" | "created";
export type SortMode = "modified" | "title" | "created" | "custom";
export type ViewMode =
| "all"
| "notebook"
+9 -2
View File
@@ -4,6 +4,7 @@
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
import { get } from 'svelte/store';
import { darkThemes } from '$lib/platform';
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
let { children } = $props();
@@ -15,9 +16,15 @@
applyTheme($theme);
});
// Apply link arrow visibility from config
$effect(() => {
if ($appConfig) {
document.documentElement.classList.toggle('no-link-arrows', !$appConfig.show_link_arrows);
}
});
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', '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'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
+5 -2
View File
@@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, setFontSize } from '$lib/api';
import { darkThemes } from '$lib/platform';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte';
@@ -120,8 +121,7 @@
// Apply theme immediately to prevent flash
const themeValue = config.theme || 'system';
const root = document.documentElement;
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', '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'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (namedThemes.includes(themeValue)) {
@@ -138,6 +138,9 @@
if (config.line_height) {
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
}
if (config.content_width) {
document.documentElement.style.setProperty('--editor-content-width', `${config.content_width}px`);
}
if (config.font_family) {
const fontStacks: Record<string, string> = {
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',