Compare commits

..
19 Commits
Author SHA1 Message Date
Yuri Karamian 3607cf1b3f Merge branch 'main' into 'main'
feat: add custom tag icons and colors

See merge request ArkHost/HelixNotes!9
2026-09-21 09:08:33 +00:00
Blake Boone 245cdacb68 feat: add custom tag icons and colors 2026-09-21 09:08:33 +00:00
Yuri Karamian 2983e3db29 Merge branch 'main' into 'main'
Merge branch 'fix/sort-theme-list-alphabetically' into 'main'

Closes #250

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

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

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

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

Closes #245

See merge request ArkHost/HelixNotes!5
2026-09-03 19:07:34 +00:00
Sridhar 2aa572dcaa fix: paste images copied from a browser on WebKitGTK 2026-09-03 19:07:34 +00:00
Yuri Karamian 32f270ce9e Update v1.3.5 download documentation 2026-08-27 00:37:30 +02:00
Yuri Karamian a15b267614 Add scroll font size preference and bump v1.3.5 2026-08-26 17:16:16 +02:00
Yuri Karamian ce48d77cd8 fix: stop wiki links extending after autocomplete 2026-08-23 15:38:57 +02:00
36 changed files with 1884 additions and 213 deletions
+15 -13
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.4-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
[![Latest Release](https://img.shields.io/badge/release-v1.3.5-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download (v1.3.4)
## Download (v1.3.5)
### Linux
@@ -18,7 +18,7 @@ No cloud, no lock-in.
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.AppImage)
#### Distro-specific packages
@@ -108,26 +108,28 @@ sudo eopkg it helixnotes
#### Manual package downloads
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
- [.deb](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
### Windows
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
[Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
>
> ```bash
> xattr -cr /Applications/HelixNotes.app
> ```
[Download .dmg (Intel)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64.dmg)
The Apple Silicon build is signed and notarized by Apple. The Intel build may still trigger a Gatekeeper warning. If macOS reports that the Intel build is damaged, run this once after installing or updating it:
```bash
xattr -cr /Applications/HelixNotes.app
```
### Android
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
---
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.4",
"version": "1.3.5",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -11,7 +11,7 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "node --test tests/*.test.mjs",
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
+1 -1
View File
@@ -1776,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.4"
version = "1.3.5"
dependencies = [
"arboard",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.3.4"
version = "1.3.5"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
+46 -6
View File
@@ -470,6 +470,22 @@ pub fn set_font_size(app: AppHandle, state: State<'_, AppState>, size: u32) -> R
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_scroll_to_change_font_size(
app: AppHandle,
state: State<'_, AppState>,
enabled: bool,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.scroll_to_change_font_size = enabled;
save_app_config(&config)?;
drop(config);
use tauri::Emitter;
app.emit("scroll-to-change-font-size-changed", enabled)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
@@ -1572,6 +1588,28 @@ pub fn set_notebook_icon(
operations::set_notebook_icon(vault_path, &notebook_relative, icon_relative.as_deref())
}
// ── Tag Styles ──
#[tauri::command]
pub fn get_tag_styles(
state: State<'_, AppState>,
) -> Result<std::collections::HashMap<String, crate::types::TagStyle>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::load_tag_styles(vault_path)
}
#[tauri::command]
pub fn set_tag_style(
state: State<'_, AppState>,
tag: String,
style: Option<crate::types::TagStyle>,
) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::set_tag_style(vault_path, &tag, style)
}
// ── General Settings ──
#[tauri::command]
@@ -1787,7 +1825,7 @@ pub struct OrphanAttachment {
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files)
// plus notebook_icons.json, and matches each filename against both the raw text and a
// plus notebook_icons.json and tag_styles.json, and matches each filename against both the raw text and a
// percent-decoded copy (so a URL-encoded path like `my%20file.png` still counts as a reference).
// When in doubt a file is KEPT: a leftover orphan is harmless, a wrong deletion is not.
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
@@ -1825,11 +1863,13 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
}
}
}
// Folder icons live in attachments but are referenced here, not in notes. (#157)
let icons_path = operations::helixnotes_dir(vault).join("notebook_icons.json");
if let Ok(content) = std::fs::read_to_string(&icons_path) {
haystack.push_str(&content);
haystack.push('\n');
// Folder and tag icons live in attachments but are referenced here, not in notes. (#157)
for mapping in ["notebook_icons.json", "tag_styles.json"] {
let mapping_path = operations::helixnotes_dir(vault).join(mapping);
if let Ok(content) = std::fs::read_to_string(&mapping_path) {
haystack.push_str(&content);
haystack.push('\n');
}
}
let decoded = percent_decode(&haystack);
let orphans = files
+3
View File
@@ -172,6 +172,7 @@ pub fn run() {
commands::export_custom_theme,
commands::import_custom_themes,
commands::set_font_size,
commands::set_scroll_to_change_font_size,
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
@@ -215,6 +216,8 @@ pub fn run() {
commands::save_attachment,
commands::get_notebook_icons,
commands::set_notebook_icon,
commands::get_tag_styles,
commands::set_tag_style,
commands::set_general_settings,
commands::get_quick_access,
commands::add_quick_access,
+9 -5
View File
@@ -6,9 +6,9 @@
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
// and the manifest itself remain local-only.
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`,
// `.helixnotes/notebook_icons.json`, and `.helixnotes/tag_styles.json`. Search indexes,
// trash, history, other metadata, and the manifest itself remain local-only.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
@@ -123,11 +123,13 @@ struct LocalFile {
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
/// `.helixnotes/attachments/`, plus notebook icon and tag style mappings. Applied to BOTH local
/// and remote so pointing at a folder with unrelated files never imports them.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
rel.starts_with(".helixnotes/attachments/")
|| rel == ".helixnotes/notebook_icons.json"
|| rel == ".helixnotes/tag_styles.json"
} else {
rel.ends_with(".md")
}
@@ -758,6 +760,7 @@ mod tests {
"Notes/plan.md",
".helixnotes/attachments/notebook-icon.png",
".helixnotes/notebook_icons.json",
".helixnotes/tag_styles.json",
] {
assert!(is_synced_relpath(path), "expected {path} to be synced");
}
@@ -766,6 +769,7 @@ mod tests {
"Notes/image.png",
".helixnotes/sync_state.json",
".helixnotes/notebook_icons.json.bak",
".helixnotes/tag_styles.json.bak",
".helixnotes/attachments-old/icon.png",
] {
assert!(!is_synced_relpath(path), "expected {path} to stay local");
+40
View File
@@ -42,6 +42,28 @@ pub struct NotebookEntry {
pub note_count: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TagStyle {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl TagStyle {
pub fn is_empty(&self) -> bool {
self.icon
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
&& self
.color
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteContent {
pub path: String,
@@ -124,6 +146,8 @@ pub struct AppConfig {
pub accent_color: Option<String>,
#[serde(default)]
pub font_size: Option<u32>,
#[serde(default = "default_true")]
pub scroll_to_change_font_size: bool,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
@@ -296,6 +320,7 @@ impl Default for AppConfig {
system_dark_theme: default_system_dark_theme(),
accent_color: None,
font_size: None,
scroll_to_change_font_size: true,
font_family: None,
line_height: None,
ui_scale: None,
@@ -554,4 +579,19 @@ mod startup_view_tests {
assert!(!config.show_note_switcher);
}
#[test]
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
let config = AppConfig::default();
assert!(config.scroll_to_change_font_size);
let mut value = serde_json::to_value(config).unwrap();
value
.as_object_mut()
.unwrap()
.remove("scroll_to_change_font_size");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(config.scroll_to_change_font_size);
}
}
+236 -3
View File
@@ -1,5 +1,5 @@
use crate::types::{
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TagStyle, TrashContents,
TrashNotebookEntry, VaultState,
};
use crate::vault::frontmatter;
@@ -1448,6 +1448,100 @@ pub fn set_notebook_icon(
Ok(())
}
fn tag_styles_path(vault_path: &str) -> PathBuf {
helixnotes_dir(vault_path).join("tag_styles.json")
}
fn normalize_tag_style_key(tag: &str) -> String {
tag.trim().to_string()
}
fn tag_style_key_fold(tag: &str) -> String {
tag.to_ascii_lowercase()
}
fn remove_matching_tag_styles(styles: &mut std::collections::HashMap<String, TagStyle>, tag: &str) {
let fold = tag_style_key_fold(tag);
styles.retain(|existing, _| tag_style_key_fold(existing) != fold);
}
fn normalize_tag_color(color: Option<&str>) -> Result<Option<String>, String> {
let Some(color) = color.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let hex = color
.strip_prefix('#')
.ok_or_else(|| "Tag color must be a #hex value".to_string())?;
if !matches!(hex.len(), 3 | 6) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Tag color must be #RGB or #RRGGBB".to_string());
}
Ok(Some(format!("#{}", hex.to_ascii_lowercase())))
}
fn normalize_tag_icon(icon: Option<&str>) -> Option<String> {
icon.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.replace('\\', "/"))
}
pub fn load_tag_styles(
vault_path: &str,
) -> Result<std::collections::HashMap<String, TagStyle>, String> {
let styles_path = tag_styles_path(vault_path);
if !styles_path.exists() {
return Ok(std::collections::HashMap::new());
}
let data = fs::read_to_string(&styles_path).map_err(|e| e.to_string())?;
let styles: std::collections::HashMap<String, TagStyle> =
serde_json::from_str(&data).map_err(|e| e.to_string())?;
let mut collapsed = std::collections::HashMap::new();
for (tag, style) in styles {
let key = normalize_tag_style_key(&tag);
if key.is_empty() || style.is_empty() {
continue;
}
remove_matching_tag_styles(&mut collapsed, &key);
collapsed.insert(key, style);
}
Ok(collapsed)
}
pub fn set_tag_style(vault_path: &str, tag: &str, style: Option<TagStyle>) -> Result<(), String> {
let key = normalize_tag_style_key(tag);
if key.is_empty() {
return Err("Tag name is required".to_string());
}
let mut styles = load_tag_styles(vault_path)?;
let stored_key = styles
.keys()
.find(|existing| tag_style_key_fold(existing) == tag_style_key_fold(&key))
.cloned()
.unwrap_or_else(|| key.clone());
match style {
Some(style) => {
let next = TagStyle {
icon: normalize_tag_icon(style.icon.as_deref()),
color: normalize_tag_color(style.color.as_deref())?,
};
remove_matching_tag_styles(&mut styles, &key);
if !next.is_empty() {
styles.insert(stored_key, next);
}
}
None => {
remove_matching_tag_styles(&mut styles, &key);
}
}
let dir = helixnotes_dir(vault_path);
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let data = serde_json::to_string_pretty(&styles).map_err(|e| e.to_string())?;
fs::write(tag_styles_path(vault_path), data).map_err(|e| e.to_string())?;
Ok(())
}
pub fn load_quick_access(vault_path: &str) -> Result<Vec<String>, String> {
let qa_path = helixnotes_dir(vault_path).join("quick_access.json");
if qa_path.exists() {
@@ -1566,9 +1660,10 @@ pub fn sanitize_filename(name: &str) -> String {
mod tests {
use super::{
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
scan_notebooks, set_notebook_icon,
helixnotes_dir, load_notebook_icons, load_tag_styles, permanent_delete, read_note,
restore_notebook, scan_notebooks, set_notebook_icon, set_tag_style,
};
use crate::types::TagStyle;
use std::fs;
use uuid::Uuid;
@@ -1660,6 +1755,144 @@ mod tests {
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn persists_and_removes_tag_styles() {
let vault =
std::env::temp_dir().join(format!("helixnotes-tag-style-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
set_tag_style(
&vault_path,
" Work ",
Some(TagStyle {
icon: Some("builtin:briefcase".into()),
color: Some("#E11D48".into()),
}),
)
.unwrap();
let stored: std::collections::HashMap<String, TagStyle> = serde_json::from_str(
&fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(),
)
.unwrap();
assert!(stored.contains_key("Work"));
assert!(!stored.contains_key(" Work "));
let styles = load_tag_styles(&vault_path).unwrap();
let work = styles.get("Work").expect("work tag style");
assert_eq!(work.icon.as_deref(), Some("builtin:briefcase"));
assert_eq!(work.color.as_deref(), Some("#e11d48"));
set_tag_style(&vault_path, "Work", None).unwrap();
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn resetting_tag_style_is_case_insensitive() {
let vault =
std::env::temp_dir().join(format!("helixnotes-tag-style-case-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
set_tag_style(
&vault_path,
"Work",
Some(TagStyle {
icon: Some("builtin:briefcase".into()),
color: Some("#e11d48".into()),
}),
)
.unwrap();
set_tag_style(&vault_path, "work", None).unwrap();
let stored: serde_json::Value = serde_json::from_str(
&fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(),
)
.unwrap();
assert_eq!(stored, serde_json::json!({}));
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn load_collapses_case_variant_tag_style_keys() {
let vault = std::env::temp_dir().join(format!(
"helixnotes-tag-style-collapse-test-{}",
Uuid::new_v4()
));
let vault_path = vault.to_string_lossy();
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
fs::write(
helixnotes_dir(&vault_path).join("tag_styles.json"),
r##"{"Work":{"icon":"builtin:briefcase"},"work":{"color":"#e11d48"}}"##,
)
.unwrap();
let styles = load_tag_styles(&vault_path).unwrap();
assert_eq!(styles.len(), 1);
let (key, style) = styles.iter().next().unwrap();
assert_eq!(key.to_ascii_lowercase(), "work");
assert!(style.icon.is_some() || style.color.is_some());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn rejects_invalid_tag_color() {
let vault = std::env::temp_dir().join(format!(
"helixnotes-tag-style-color-test-{}",
Uuid::new_v4()
));
let vault_path = vault.to_string_lossy();
let err = set_tag_style(
&vault_path,
"daily",
Some(TagStyle {
icon: None,
color: Some("red".into()),
}),
)
.unwrap_err();
assert!(err.contains("hex"));
fs::remove_dir_all(&vault).ok();
}
#[test]
fn clearing_empty_tag_style_removes_entry() {
let vault = std::env::temp_dir().join(format!(
"helixnotes-tag-style-clear-test-{}",
Uuid::new_v4()
));
let vault_path = vault.to_string_lossy();
set_tag_style(
&vault_path,
"daily",
Some(TagStyle {
icon: Some("builtin:calendar".into()),
color: None,
}),
)
.unwrap();
set_tag_style(
&vault_path,
"daily",
Some(TagStyle {
icon: None,
color: None,
}),
)
.unwrap();
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn loads_only_requested_note_switcher_titles() {
let vault =
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.4",
"version": "1.3.5",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+8
View File
@@ -2,7 +2,15 @@
"app": {
"windows": [
{
"title": "HelixNotes",
"label": "main",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 500,
"resizable": true,
"fullscreen": false,
"decorations": false,
"visible": false
}
]
+16
View File
@@ -11,6 +11,7 @@ import type {
TrashContents,
VaultState,
VaultStats,
TagStyle,
ImportResult,
BackupEntry,
VersionEntry,
@@ -71,6 +72,10 @@ export async function setFontSize(size: number): Promise<void> {
return invoke("set_font_size", { size });
}
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
return invoke("set_scroll_to_change_font_size", { enabled });
}
export async function setFontFamily(family: string): Promise<void> {
return invoke("set_font_family", { family });
}
@@ -265,6 +270,17 @@ export async function setNotebookIcon(
return invoke("set_notebook_icon", { notebookRelative, iconRelative });
}
export async function getTagStyles(): Promise<Record<string, TagStyle>> {
return invoke("get_tag_styles");
}
export async function setTagStyle(
tag: string,
style: TagStyle | null,
): Promise<void> {
return invoke("set_tag_style", { tag, style });
}
export async function setGeneralSettings(
compactNotes: boolean,
timeFormat: string,
+10 -1
View File
@@ -10,6 +10,7 @@
import InfoPanel from './InfoPanel.svelte';
import TitleBar from './TitleBar.svelte';
import ResizeHandle from './ResizeHandle.svelte';
import TagLabel from './TagLabel.svelte';
import { getCurrentWindow } from '@tauri-apps/api/window';
import {
sidebarWidth,
@@ -578,6 +579,11 @@
}
if (e.key === 'Escape') {
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
if (!dismissesAppUi) return;
e.preventDefault();
e.stopPropagation();
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
else if ($focusMode) $focusMode = false;
@@ -850,7 +856,7 @@
{#if $mobileView === 'sidebar'}
HelixNotes
{:else}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag' && $activeTag}<TagLabel name={$activeTag} size={16} />{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if}
</span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -1240,6 +1246,9 @@
.mobile-header-title {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
+9 -4
View File
@@ -164,10 +164,15 @@
}
});
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
$showCommandPalette = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
$showCommandPalette = false;
} else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
} else if (e.key === 'ArrowUp') {
@@ -194,7 +199,7 @@
{#if $showCommandPalette}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydown={handleKeydown}>
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<div class="palette-input-wrapper">
+64 -107
View File
@@ -54,9 +54,13 @@
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
import { clearFormatting } from '$lib/editor/clearFormatting';
import { serializeInlineMarkdown } from '$lib/editor/markdown';
import { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility';
import { tagIterationKey } from '$lib/utils/tag-styles';
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
import GraphView from './GraphView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
import TagLabel from './TagLabel.svelte';
import ImageViewer from './ImageViewer.svelte';
import { isMobile, isAndroid } from '$lib/platform';
import ResizeHandle from './ResizeHandle.svelte';
@@ -116,9 +120,7 @@
let hasPendingBlobs = false;
let lastSourceMode = $sourceMode;
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
let titleWasStripped = false;
let strippedTitle = '';
let strippedHeadingPrefix = '';
let hiddenTitleHeading: HiddenTitleHeading | null = null;
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
let taskRevealElement: HTMLElement | null = null;
let taskRevealRequest = 0;
@@ -2547,10 +2549,7 @@
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
if (!editor || !wikiLinkMenu) return;
const { from } = wikiLinkMenu;
// Delete the [[ trigger and query text
const to = editor.state.selection.from;
editor.chain().focus().deleteRange({ from, to }).run();
// Insert the wiki-link mark
// For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
const displayText = entry.title;
let titleAttr = originalRef || entry.title;
@@ -2562,17 +2561,16 @@
titleAttr = relPath + anchor;
}
}
tick().then(() => {
if (!editor) return;
editor.chain().focus()
.insertContent({
type: 'text',
text: displayText,
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }],
})
.run();
});
const transaction = replaceWithWikiLink(
editor.state,
from,
to,
displayText,
{ title: titleAttr, path: entry.path || '', aliased: displayText !== titleAttr },
);
closeWikiLinkMenu();
editor.view.dispatch(transaction);
editor.view.focus();
}
function executeWikiLinkCommand(index: number) {
@@ -2759,21 +2757,13 @@
closeWikiLinkMenu();
tick().then(() => {
if (!editor) return;
// Use a single ProseMirror transaction to replace [[query] with the
// wiki-link and clear stored marks atomically, preventing the inclusive
// mark from bleeding into subsequent text. Do NOT call deleteRange first
// — that would shift positions and make menuFrom/curTo invalid here.
const { tr, schema } = editor.view.state;
const wikiLinkMark = schema.marks.wikiLink.create({
title: noteRef,
path: '',
aliased: display !== noteRef,
});
const textNode = schema.text(display, [wikiLinkMark]);
tr.replaceWith(menuFrom, curTo, textNode);
tr.setSelection(TextSelection.create(tr.doc, menuFrom + display.length));
tr.setStoredMarks([]);
editor.view.dispatch(tr);
editor.view.dispatch(replaceWithWikiLink(
editor.state,
menuFrom,
curTo,
display,
{ title: noteRef, path: '', aliased: display !== noteRef },
));
});
}
} else {
@@ -3414,44 +3404,13 @@
}
function stripTitleH1(md: string): string {
const title = $activeNote?.meta.title;
if (!$appConfig?.hide_title_in_body || !title) {
titleWasStripped = false;
strippedTitle = '';
strippedHeadingPrefix = '';
return md;
}
// Find the first non-empty line
const lines = md.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
// Check if it's a heading (any level) matching the note title
// Normalize: lowercase, collapse whitespace, strip common separators (- - _)
const normalize = (s: string) => s.trim().toLowerCase().replace(/[\s\-—_]+/g, ' ');
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match && normalize(match[2]) === normalize(title)) {
titleWasStripped = true;
strippedTitle = title.trim();
strippedHeadingPrefix = match[1]; // preserve original heading level (e.g. "##")
lines.splice(i, 1);
// Also remove a trailing blank line after the heading if present
if (i < lines.length && lines[i].trim() === '') {
lines.splice(i, 1);
}
return lines.join('\n');
}
break; // First non-empty line isn't a matching heading, stop
}
titleWasStripped = false;
strippedTitle = '';
strippedHeadingPrefix = '';
return md;
const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false);
hiddenTitleHeading = result.hiddenTitle;
return result.markdown;
}
function restoreTitleH1(md: string): string {
if (!titleWasStripped || !strippedTitle) return md;
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
return restoreTitleHeading(md, hiddenTitleHeading);
}
function editorToMarkdown(): string {
@@ -5007,13 +4966,25 @@
}
async function ctxPaste() {
if (!editor) return;
// insertClipboardImage() saves the attachment before inserting it, so an
// unguarded paste here would leave an orphan file behind when the editor
// cannot accept the insertion.
if (!editor || $readOnly || $viewerNote) { closeTextContextMenu(); return; }
try {
const text = await navigator.clipboard.readText();
if (text) editor.chain().focus().insertContent(text).run();
if (text) {
editor.chain().focus().insertContent(text).run();
closeTextContextMenu();
return;
}
} catch (e) {
console.error('Paste failed:', e);
}
// readText() only reads text/plain. An image copied from a browser has none,
// so pasting an image from this menu used to do nothing at all. Fall back to
// the same native clipboard reader the paste handler uses; it is a no-op when
// the clipboard holds no image.
await insertClipboardImage();
closeTextContextMenu();
}
@@ -5640,17 +5611,20 @@
}
return true;
}
// WebKitGTK fallback (bug #218519): older WebKitGTK versions return
// empty DataTransferItemList for image pastes. Detect this and read
// the image directly from the system clipboard via Rust/arboard.
if (items.length === 0) {
const hasText = event.clipboardData!.getData('text/plain');
const hasHtml = event.clipboardData!.getData('text/html');
if (!hasText && !hasHtml) {
event.preventDefault();
insertClipboardImage();
return true;
}
// WebKitGTK fallback (bug #218519): WebKitGTK never exposes a clipboard image
// through DataTransfer, so the loop above cannot fire on Linux and this is the
// only path that can work there. The guard used to also require an empty
// DataTransferItemList and no text/html, which no real image copy satisfies:
// Chromium's "Copy image" writes an <img> HTML fragment alongside the bitmap
// (but no text/plain), so the image was dropped and its remote URL pasted
// instead. Key off text/plain, and accept HTML that is just an <img>.
const hasText = event.clipboardData!.getData('text/plain');
const html = event.clipboardData!.getData('text/html');
const htmlIsBareImage = /^\s*(?:<meta[^>]*>\s*)?<img\b[^>]*>\s*$/i.test(html);
if (!hasText && (!html || htmlIsBareImage)) {
event.preventDefault();
insertClipboardImage();
return true;
}
return false;
}
@@ -5790,7 +5764,7 @@
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
: 0;
const docNonWs = docNonWhitespace();
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? ''));
resetSourceHistory(sourceContent);
lastSourceMode = true;
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
@@ -5817,20 +5791,20 @@
};
if (isMobile) {
// Mobile: editor stays in DOM, just update its content
const content = srcText || ($activeNote?.content ?? '');
const content = srcText;
if (editor) {
ignoreNextUpdate = true;
editor.commands.setContent(markdownToHtml(content));
editor.commands.setContent(markdownToHtml(restoreTitleH1(content)));
tick().then(restoreRichCaret);
}
} else {
// Desktop: destroy old editor (its DOM element is gone),
// wait for DOM to swap textarea→div, then create editor on new element.
destroyEditor();
const content = srcText || ($activeNote?.content ?? '');
const content = srcText;
tick().then(() => {
if (editorElement && !editor) {
createEditor(content);
createEditor(restoreTitleH1(content));
restoreRichCaret();
}
});
@@ -5963,7 +5937,7 @@
const oldPath = $activeNotePath;
$activeNote.meta.title = newTitle;
// Update stripped title so restoreTitleH1 uses the new title
if (titleWasStripped) strippedTitle = newTitle;
if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle };
$editorDirty = true;
// Force save current editor content before renaming so disk is up-to-date
await forceSave();
@@ -6164,7 +6138,7 @@
<span class="meta-divider">·</span>
<button class="note-tags-trigger" onclick={toggleTagMenu} title="Edit tags">
{#if $activeNote.meta.tags?.length > 0}
{#each $activeNote.meta.tags as tag}<span class="note-tag">#{tag}</span>{/each}
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}<TagLabel name={tag} size={11} tone="muted" />{/each}
{:else}
<span class="note-tags-add">+ Tags</span>
{/if}
@@ -6489,8 +6463,8 @@
<div class="info-row info-row-tags">
<span class="info-key">Tags</span>
<span class="info-value info-tags">
{#each $activeNote.meta.tags as tag}
<span class="info-tag">#{tag}</span>
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}
<TagLabel name={tag} size={11} tone="muted" />
{/each}
</span>
</div>
@@ -7487,9 +7461,9 @@
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
{#if $activeNote.meta.tags.length > 0}
<div class="tag-menu-list">
{#each $activeNote.meta.tags as tag}
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}
<span class="tag-menu-chip">
#{tag}
<TagLabel name={tag} size={12} />
<button class="tag-menu-remove" onclick={() => removeActiveNoteTag(tag)} title="Remove tag" aria-label="Remove tag">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -8046,15 +8020,6 @@
cursor: pointer;
}
.note-tag {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 1px 7px;
border-radius: 10px;
letter-spacing: 0.01em;
}
.note-tags-add {
font-size: 11px;
color: var(--text-tertiary);
@@ -11721,14 +11686,6 @@
justify-content: flex-end;
}
.info-tag {
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
border-radius: 3px;
padding: 1px 5px;
}
.editor-container.mobile .editor-body-row:has(.info-panel) > .editor-body {
display: none;
}
+8 -1
View File
@@ -95,6 +95,13 @@
if (event.target === event.currentTarget) close();
}
function handleEscape(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
close();
}
function openLink(url: string) {
openUrl(url).catch(console.error);
}
@@ -104,7 +111,7 @@
{#if $showInfo}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<div class="info-overlay" onclick={closeFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
<div class="info-panel">
<div class="info-header">
<h2>Info</h2>
+22 -18
View File
@@ -43,9 +43,11 @@
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { tagIterationKey } from '$lib/utils/tag-styles';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
import TagLabel from './TagLabel.svelte';
import { isMobile, isAndroid } from '$lib/platform';
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
@@ -1016,7 +1018,13 @@
<div class="note-list" class:mobile={isMobile}>
<div class="list-header">
<span class="list-title">{viewTitle}</span>
<span class="list-title">
{#if $viewMode === 'tag' && $activeTag}
<TagLabel name={$activeTag} size={16} />
{:else}
{viewTitle}
{/if}
</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">
@@ -1133,9 +1141,9 @@
</div>
{#if tagEditTags.length > 0}
<div class="tag-edit-list">
{#each tagEditTags as tag}
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
<div class="tag-edit-item">
<span class="tag-edit-name">#{tag}</span>
<span class="tag-edit-name"><TagLabel name={tag} size={12} /></span>
<button class="tag-edit-remove" onclick={() => removeTagFromBatch(tag)} title="Remove from all">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -1377,8 +1385,8 @@
{#if showDates}<span class="note-date" title={`Created ${formatDate(note.meta.created)}\nModified ${formatDate(note.meta.modified)}`}>{formatRelativeTime($sortMode === 'created' ? note.meta.created : note.meta.modified)}</span>{/if}
{#if note.meta.tags.length > 0}
<span class="note-tags">
{#each note.meta.tags.slice(0, 3) as tag}
<span class="mini-tag">#{tag}</span>
{#each note.meta.tags.slice(0, 3) as tag, i (tagIterationKey(tag, i))}
<TagLabel name={tag} size={10} tone="accent" />
{/each}
</span>
{/if}
@@ -1445,9 +1453,9 @@
</div>
{#if tagEditTags.length > 0}
<div class="tag-edit-list">
{#each tagEditTags as tag}
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
<div class="tag-edit-item">
<span class="tag-edit-name">#{tag}</span>
<span class="tag-edit-name"><TagLabel name={tag} size={12} /></span>
<button class="tag-edit-remove" onclick={() => removeTagFromBatch(tag)} title="Remove from all">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -1497,9 +1505,9 @@
</div>
{#if tagEditTags.length > 0}
<div class="tag-edit-list">
{#each tagEditTags as tag}
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
<div class="tag-edit-item">
<span class="tag-edit-name">#{tag}</span>
<span class="tag-edit-name"><TagLabel name={tag} size={12} /></span>
<button class="tag-edit-remove" onclick={() => removeTagFromNote(tag)} title="Remove tag">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -1683,6 +1691,10 @@
}
.list-title {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
@@ -2018,14 +2030,6 @@
gap: 4px;
}
.mini-tag {
font-size: 10px;
color: var(--text-accent);
background: var(--accent-light);
padding: 1px 5px;
border-radius: 3px;
}
.rename-input {
width: 100%;
padding: 4px 8px;
@@ -2290,7 +2294,7 @@
font-size: 12px;
}
.note-list.mobile .mini-tag {
.note-list.mobile :global(.tag-label.tone-accent) {
font-size: 11px;
padding: 2px 6px;
}
+9 -2
View File
@@ -10,9 +10,10 @@
activeNotePath,
editorDirty,
readOnly,
sourceMode
sourceMode,
tagStyles
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import { getTagStyles, readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
import type { FileEvent } from '$lib/types';
@@ -85,6 +86,12 @@
});
await applyUiScale($appConfig?.ui_scale ?? 1);
try {
$tagStyles = await getTagStyles();
} catch (e) {
console.error('Failed to load tag styles:', e);
}
try {
const content = await readNote(notePath);
$activeNote = content;
+9 -4
View File
@@ -44,10 +44,15 @@
if (item) item.scrollIntoView({ block: 'nearest' });
}
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
$showSearch = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
$showSearch = false;
} else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
scrollToSelected();
@@ -128,7 +133,7 @@
{#if $showSearch}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="search-overlay" onclick={close} onkeydown={handleKeydown}>
<div class="search-overlay" onclick={close} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<div class="search-input-wrapper">
+44 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
@@ -521,6 +521,17 @@
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
];
// Keep the default themes pinned at the top and sort the rest alphabetically.
const priorityThemeIds = ['system', 'light', 'dark'];
themePresets.sort((a, b) => {
const pa = priorityThemeIds.indexOf(a.id);
const pb = priorityThemeIds.indexOf(b.id);
if (pa !== -1 && pb !== -1) return pa - pb;
if (pa !== -1) return -1;
if (pb !== -1) return 1;
return a.label.localeCompare(b.label);
});
const accentPresets = [
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
@@ -583,6 +594,7 @@
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14);
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
@@ -987,6 +999,13 @@
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
}
function toggleScrollToChangeFontSize() {
scrollToChangeFontSize = !scrollToChangeFontSize;
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
setScrollToChangeFontSize(scrollToChangeFontSize)
.catch((e) => console.error('Failed to save scroll font size setting:', e));
}
function applyFontSize(size: number) {
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
}
@@ -1060,6 +1079,15 @@
if (event.target === event.currentTarget) close();
}
function handleEscape(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
if (restoreConfirm) restoreConfirm = null;
else if (customThemeEditorOpen) cancelCustomThemeEditor();
else close();
}
function dismissRestoreConfirm(event: MouseEvent) {
if (event.target === event.currentTarget) restoreConfirm = null;
}
@@ -1080,6 +1108,7 @@
}
}
}
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
const savedSize = $appConfig?.font_size;
if (savedSize) {
activeFontSize = savedSize;
@@ -1139,7 +1168,7 @@
{#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
<div class="settings-header">
<h2 id="settings-title">Settings</h2>
@@ -1804,7 +1833,7 @@
<!-- Custom Theme Editor Modal -->
{#if customThemeEditorOpen && customThemeEditing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={handleEscape}>
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
<div class="custom-theme-modal-header">
<h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
@@ -1910,6 +1939,17 @@
</button>
{/each}
</div>
{#if !isMobile}
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Scroll to change font size</span>
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
</span>
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
<span class="toggle-knob"></span>
</button>
</label>
{/if}
</div>
{#if !isMobile}
@@ -2101,7 +2141,7 @@
{#if restoreConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={(e) => { if (e.key === 'Escape') restoreConfirm = null; }}>
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={handleEscape}>
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
<h4 id="restore-confirm-title">Restore Backup?</h4>
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
+92 -36
View File
@@ -14,6 +14,7 @@
showSettings,
showInfo,
notebookIcons,
tagStyles,
appConfig,
quickAccessPaths,
collapsedNotebooks,
@@ -21,7 +22,7 @@
notebookSortMode,
notebookOrder
} from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, getTagStyles, 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';
@@ -29,6 +30,8 @@
import { isMobile } from '$lib/platform';
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
import NotebookGlyph from './NotebookGlyph.svelte';
import TagLabel from './TagLabel.svelte';
import TagStylePicker from './TagStylePicker.svelte';
import {
NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon,
@@ -91,6 +94,8 @@
let iconPickerElement = $state<HTMLDivElement | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let tagContextMenu = $state<{ x: number; y: number; tag: string } | null>(null);
let stylePickerTag = $state<string | null>(null);
let deleteConfirm = $state<NotebookEntry | null>(null);
function toggleCollapse(nb: NotebookEntry, e: Event) {
e.stopPropagation();
@@ -122,14 +127,16 @@
try {
if (isMobile) {
// On mobile, parallelize and skip getAllTags (derive from $notes instead)
const [nbs, icons, qaNotes, rootCount] = await Promise.all([
const [nbs, icons, styles, qaNotes, rootCount] = await Promise.all([
getNotebooks(),
getNotebookIcons(),
getTagStyles(),
getQuickAccess(),
countRootNotes(),
]);
$notebooks = nbs;
$notebookIcons = icons;
$tagStyles = styles;
$quickAccessPaths = qaNotes.map(n => n.relative_path);
$rootNoteCount = rootCount;
} else {
@@ -138,6 +145,7 @@
$rootNoteCount = rootCount;
$tags = await getAllTags();
$notebookIcons = await getNotebookIcons();
$tagStyles = await getTagStyles();
const qaNotes = await getQuickAccess();
$quickAccessPaths = qaNotes.map(n => n.relative_path);
}
@@ -670,6 +678,26 @@
contextMenu = null;
}
function onTagContextMenu(e: MouseEvent, tag: string) {
e.preventDefault();
e.stopPropagation();
const { x, y } = clampMenu(e.clientX, e.clientY, 200, 80);
tagContextMenu = { x, y, tag };
}
function openTagMenu(e: Event, tag: string) {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 80);
tagContextMenu = { x, y, tag };
}
function openTagStylePicker(tag: string) {
tagContextMenu = null;
stylePickerTag = tag;
}
function onTrashContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
@@ -695,12 +723,13 @@
function handleWindowClick() {
if (contextMenu) contextMenu = null;
if (trashContextMenu) trashContextMenu = null;
if (tagContextMenu) tagContextMenu = null;
}
</script>
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') { iconPickerNotebook = null; tagContextMenu = null; } }} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
<aside class="sidebar" class:collapsed={!isMobile && $sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile}
<div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
@@ -721,7 +750,7 @@
</div>
{/if}
{#if !$sidebarCollapsed}
{#if isMobile || !$sidebarCollapsed}
{#if anyNavItem}
<nav class="sidebar-nav">
{#if $appConfig?.show_all_notes !== false}
@@ -885,16 +914,8 @@
</button>
{#if !tagsCollapsed}
<div class="tag-list">
{#each $tags as [tag, count]}
<button
class="tag-item"
class:active={$viewMode === 'tag' && $activeTag === tag}
onclick={() => selectTag(tag)}
>
<span class="tag-hash">#</span>
<span class="tag-name">{tag}</span>
<span class="tag-count">{count}</span>
</button>
{#each $tags as [tag, count] (tag)}
{@render tagRow(tag, count)}
{/each}
</div>
{/if}
@@ -910,16 +931,8 @@
</button>
{#if !tagsCollapsed}
<div class="tag-list">
{#each $tags as [tag, count]}
<button
class="tag-item"
class:active={$viewMode === 'tag' && $activeTag === tag}
onclick={() => selectTag(tag)}
>
<span class="tag-hash">#</span>
<span class="tag-name">{tag}</span>
<span class="tag-count">{count}</span>
</button>
{#each $tags as [tag, count] (tag)}
{@render tagRow(tag, count)}
{/each}
</div>
{/if}
@@ -1016,6 +1029,23 @@
</div>
{/if}
{#if tagContextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close tag actions" onclick={() => tagContextMenu = null}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" class:mobile={isMobile} style="left: {tagContextMenu.x}px; top: {tagContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button onclick={() => openTagStylePicker(tagContextMenu!.tag)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
Customize...
</button>
</div>
{/if}
{#if stylePickerTag}
<TagStylePicker tag={stylePickerTag} onclose={() => stylePickerTag = null} />
{/if}
{#if trashContextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
@@ -1045,6 +1075,32 @@
</div>
{/if}
{#snippet tagRow(tag: string, count: number)}
<div class="tag-row">
<button
class="tag-item"
class:active={$viewMode === 'tag' && $activeTag === tag}
onclick={() => selectTag(tag)}
oncontextmenu={(e) => onTagContextMenu(e, tag)}
>
<span class="tag-label-slot">
<TagLabel name={tag} size={isMobile ? 16 : 14} />
</span>
<span class="tag-count">{count}</span>
</button>
{#if isMobile}
<button
type="button"
class="notebook-actions-btn"
aria-label={`Customize ${tag}`}
onclick={(e) => openTagMenu(e, tag)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
</button>
{/if}
</div>
{/snippet}
{#snippet notebookItem(nb: NotebookEntry, depth: number)}
{@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
@@ -1567,6 +1623,10 @@
padding: 0 4px;
}
.tag-row {
position: relative;
}
.tag-item {
display: flex;
align-items: center;
@@ -1582,6 +1642,12 @@
transition: all 0.1s;
}
.tag-label-slot {
flex: 1;
min-width: 0;
display: flex;
}
.tag-item:hover {
background: var(--bg-hover);
}
@@ -1591,16 +1657,6 @@
color: var(--text-accent);
}
.tag-hash {
color: var(--text-tertiary);
font-weight: 600;
}
.tag-name {
flex: 1;
text-align: left;
}
.tag-count {
font-size: 11px;
color: var(--text-tertiary);
@@ -2019,7 +2075,7 @@
}
.sidebar.mobile .tag-item {
padding: 10px 16px;
padding: 10px 52px 10px 16px;
min-height: 44px;
font-size: 14px;
}
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core';
import { appConfig, tagStyles } from '$lib/stores/app';
import NotebookGlyph from './NotebookGlyph.svelte';
import { decodeBuiltinNotebookIcon } from '$lib/utils/notebook-icons';
import { isValidTagColor, lookupTagStyle } from '$lib/utils/tag-styles';
let {
name,
size = 14,
tone = 'none',
showName = true
}: {
name: string;
size?: number;
tone?: 'none' | 'accent' | 'muted';
showName?: boolean;
} = $props();
const style = $derived(lookupTagStyle(name, $tagStyles));
const color = $derived.by(() => {
const value = style?.color;
return isValidTagColor(value) ? value : null;
});
const builtinIcon = $derived(decodeBuiltinNotebookIcon(style?.icon));
const imageSrc = $derived.by(() => {
const icon = style?.icon;
const vault = $appConfig?.active_vault;
if (!icon || builtinIcon || icon.startsWith('builtin:') || !vault) return null;
return convertFileSrc(`${vault}/${icon}`);
});
</script>
<span
class={[
'tag-label',
`tone-${tone}`,
{ 'has-color': !!color, 'has-icon': !!(builtinIcon || imageSrc) }
]}
style:--tag-color={color}
title={`#${name}`}
>
{#if builtinIcon}
<span class="tag-icon">
<NotebookGlyph icon={builtinIcon} {size} />
</span>
{:else if imageSrc}
<img class="tag-image" src={imageSrc} alt="" width={size} height={size} />
{:else}
<span class="tag-hash">#</span>
{/if}
{#if showName}<span class="tag-name">{name}</span>{/if}
</span>
<style>
.tag-label {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
color: inherit;
line-height: 1.2;
}
.tag-label.has-color {
color: var(--tag-color);
}
.tag-icon,
.tag-image,
.tag-hash {
flex-shrink: 0;
}
.tag-icon {
display: grid;
place-items: center;
color: inherit;
}
.tag-image {
border-radius: 3px;
object-fit: cover;
}
.tag-hash {
font-weight: 600;
color: inherit;
opacity: 0.72;
}
.tag-label.has-color .tag-hash,
.tag-label.has-icon .tag-hash,
.tone-accent .tag-hash,
.tone-muted .tag-hash {
opacity: 1;
}
.tag-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tone-accent {
gap: 3px;
padding: 1px 5px;
border-radius: 3px;
background: var(--accent-light);
color: var(--text-accent);
font-size: 10px;
}
.tone-accent.has-color {
color: var(--tag-color);
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
}
.tone-muted {
gap: 3px;
padding: 1px 7px;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 11px;
letter-spacing: 0.01em;
}
.tone-muted.has-color {
color: var(--tag-color);
background: color-mix(in srgb, var(--tag-color) 16%, var(--bg-tertiary));
}
.tone-accent .tag-hash,
.tone-muted .tag-hash {
color: inherit;
font-weight: 600;
}
</style>
+431
View File
@@ -0,0 +1,431 @@
<script lang="ts">
import { onMount } from 'svelte';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { get } from 'svelte/store';
import { tagStyles } from '$lib/stores/app';
import { saveAttachment, setTagStyle } from '$lib/api';
import type { TagStyle } from '$lib/types';
import { isMobile } from '$lib/platform';
import NotebookGlyph from './NotebookGlyph.svelte';
import TagLabel from './TagLabel.svelte';
import {
NOTEBOOK_ICON_OPTIONS,
encodeBuiltinNotebookIcon,
type NotebookIconId
} from '$lib/utils/notebook-icons';
import {
TAG_COLOR_PRESETS,
applyCommittedTagStyle,
createTagStylePersister,
lookupTagStyle,
tagColor
} from '$lib/utils/tag-styles';
let {
tag,
onclose
}: {
tag: string;
onclose: () => void;
} = $props();
let dialogEl = $state<HTMLDivElement | null>(null);
const current = $derived(lookupTagStyle(tag, $tagStyles));
const activeIcon = $derived(current?.icon ?? null);
const activeColor = $derived(tagColor(tag, $tagStyles));
const colorInputValue = $derived(expandHex(activeColor) ?? '#5b6abf');
const hasStyle = $derived(!!(activeIcon || activeColor));
const persister = createTagStylePersister({
getCurrent: () => lookupTagStyle(tag, get(tagStyles)),
save: (next) => setTagStyle(tag, next)
});
onMount(() => dialogEl?.focus());
function expandHex(color: string | null): string | null {
if (!color) return null;
if (color.length === 4) {
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
}
return color;
}
function baseOf(path: string): string {
return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
}
async function persist(patch: TagStyle) {
const next = persister.apply(patch);
$tagStyles = applyCommittedTagStyle($tagStyles, tag, next);
try {
await persister.flush();
} catch (e) {
console.error('Failed to save tag style:', e);
}
}
function handleBuiltinIcon(icon: NotebookIconId) {
void persist({ icon: encodeBuiltinNotebookIcon(icon) });
}
async function handleCustomIcon() {
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
});
if (!selected) return;
const filePath = selected as string;
const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`tag-icon-${fileName}`, Array.from(data));
await persist({ icon: iconRelative });
} catch (e) {
console.error('Failed to set tag icon:', e);
}
}
async function handleReset() {
await persist({ icon: null, color: null });
onclose();
}
function onColorInput(e: Event) {
const value = (e.currentTarget as HTMLInputElement).value;
void persist({ color: value });
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onclose();
}
}}
/>
<div class="icon-picker-overlay" class:mobile={isMobile}>
<button class="icon-picker-backdrop" aria-label="Close tag appearance picker" onclick={onclose}></button>
<div
bind:this={dialogEl}
class="icon-picker"
role="dialog"
aria-modal="true"
aria-label={`Choose an icon and color for ${tag}`}
tabindex="-1"
>
<header class="icon-picker-header">
<div>
<h3>Tag appearance</h3>
<p>
<TagLabel name={tag} size={14} />
</p>
</div>
<button type="button" class="icon-picker-close" aria-label="Close tag appearance picker" onclick={onclose}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</header>
<div class="icon-picker-grid">
{#each NOTEBOOK_ICON_OPTIONS as option (option.id)}
<button
type="button"
class="icon-picker-option"
class:active={activeIcon === encodeBuiltinNotebookIcon(option.id)}
aria-label={option.label}
title={option.label}
onclick={() => handleBuiltinIcon(option.id)}
>
<NotebookGlyph icon={option.id} size={20} />
<span>{option.label}</span>
</button>
{/each}
</div>
<div class="color-section">
<span class="color-label">Color</span>
<div class="swatches">
<button
type="button"
class="swatch none"
class:active={!activeColor}
aria-label="Use default color"
title="Default color"
onclick={() => persist({ color: null })}
></button>
{#each TAG_COLOR_PRESETS as preset (preset)}
<button
type="button"
class="swatch"
class:active={activeColor?.toLowerCase() === preset}
style:background={preset}
aria-label={`Use ${preset}`}
title={preset}
onclick={() => persist({ color: preset })}
></button>
{/each}
<label class="swatch custom" title="Custom color">
<input type="color" value={colorInputValue} oninput={onColorInput} aria-label="Custom tag color" />
</label>
</div>
</div>
<div class="icon-picker-actions">
<button type="button" onclick={handleCustomIcon}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
Custom image...
</button>
{#if hasStyle}
<button type="button" class="remove" onclick={handleReset}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
Use default
</button>
{/if}
<p class="icon-picker-recommendation">Icons and colors are optional. Recommended image size: <strong>1:1</strong></p>
</div>
</div>
</div>
<style>
.icon-picker-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 20px;
}
.icon-picker-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: rgba(8, 10, 18, 0.42);
cursor: default;
}
.icon-picker {
position: relative;
z-index: 1;
width: min(360px, 100%);
max-height: calc(100dvh - 40px);
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-lg);
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border-light);
}
.icon-picker-header h3,
.icon-picker-header p {
margin: 0;
}
.icon-picker-header h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
}
.icon-picker-header p {
margin-top: 6px;
color: var(--text-tertiary);
font-size: 12px;
}
.icon-picker-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.icon-picker-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
padding: 14px;
}
.icon-picker-option {
min-width: 0;
min-height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 3px 6px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.icon-picker-option span {
max-width: 100%;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-option:hover {
border-color: var(--border-color);
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-option.active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
}
.icon-picker-option.active span {
color: var(--accent);
}
.color-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 14px 12px;
}
.color-label {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 7px;
align-items: center;
}
.swatch {
width: 22px;
height: 22px;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
background: var(--bg-tertiary);
cursor: pointer;
}
.swatch:hover,
.swatch.active {
border-color: var(--text-primary);
}
.swatch.none {
background:
linear-gradient(to bottom right, transparent calc(50% - 1px), var(--text-tertiary) calc(50% - 1px), var(--text-tertiary) calc(50% + 1px), transparent calc(50% + 1px)),
var(--bg-secondary);
border-color: var(--border-color);
}
.swatch.custom {
display: grid;
place-items: center;
overflow: hidden;
background: conic-gradient(from 90deg, #e11d48, #eab308, #22c55e, #3b82f6, #8b5cf6, #e11d48);
}
.swatch.custom input {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: none;
cursor: pointer;
transform: scale(1.4);
}
.icon-picker-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-light);
}
.icon-picker-recommendation {
flex: 0 0 100%;
margin: 2px 0 0;
color: var(--text-tertiary);
font-size: 11px;
line-height: 1.3;
text-align: center;
}
.icon-picker-actions button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.icon-picker-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-actions button.remove {
color: var(--text-tertiary);
}
.icon-picker-overlay.mobile {
padding: 12px;
align-items: end;
}
.icon-picker-overlay.mobile .icon-picker {
width: 100%;
max-height: min(86dvh, 640px);
}
.icon-picker-overlay.mobile .icon-picker-option {
min-height: 64px;
}
.icon-picker-overlay.mobile .icon-picker-actions button {
min-height: 44px;
}
</style>
+5 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tags } from '$lib/stores/app';
import TagLabel from './TagLabel.svelte';
let { existing = [], placeholder = 'Add tag...', onsubmit, oncancel }: {
existing?: string[];
@@ -69,14 +70,14 @@
/>
{#if suggestions.length}
<div class="tag-suggest-list">
{#each suggestions as s, i}
{#each suggestions as s, i (s)}
<button
type="button"
class="tag-suggest-item"
class:selected={i === selIndex}
onmouseenter={() => (selIndex = i)}
onmousedown={(e) => { e.preventDefault(); submit(s); }}
>#{s}</button>
><TagLabel name={s} size={12} /></button>
{/each}
</div>
{/if}
@@ -110,6 +111,8 @@
gap: 1px;
}
.tag-suggest-item {
display: flex;
align-items: center;
text-align: left;
padding: 5px 8px;
border: none;
+46
View File
@@ -0,0 +1,46 @@
export type HiddenTitleHeading = {
headingPrefix: string;
title: string;
};
type TitleHeadingResult = {
markdown: string;
hiddenTitle: HiddenTitleHeading | null;
};
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/[\s\-_\u2014]+/g, ' ');
}
export function stripTitleHeading(
markdown: string,
title: string | undefined,
hideTitle: boolean,
): TitleHeadingResult {
if (!hideTitle || !title) return { markdown, hiddenTitle: null };
const lines = markdown.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match && normalizeTitle(match[2]) === normalizeTitle(title)) {
const hiddenTitle = { headingPrefix: match[1], title: title.trim() };
lines.splice(i, 1);
if (i < lines.length && lines[i].trim() === '') lines.splice(i, 1);
return { markdown: lines.join('\n'), hiddenTitle };
}
break;
}
return { markdown, hiddenTitle: null };
}
export function restoreTitleHeading(
markdown: string,
hiddenTitle: HiddenTitleHeading | null,
): string {
if (!hiddenTitle) return markdown;
return `${hiddenTitle.headingPrefix} ${hiddenTitle.title}\n\n${markdown}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { TextSelection, type EditorState, type Transaction } from '@tiptap/pm/state';
export type WikiLinkAttributes = {
title: string;
path: string;
aliased: boolean;
};
export function replaceWithWikiLink(
state: EditorState,
from: number,
to: number,
text: string,
attrs: WikiLinkAttributes
): Transaction {
const wikiLinkMark = state.schema.marks.wikiLink.create(attrs);
const textNode = state.schema.text(text, [wikiLinkMark]);
const transaction = state.tr.replaceWith(from, to, textNode);
transaction.setSelection(TextSelection.create(transaction.doc, from + text.length));
transaction.setStoredMarks([]);
return transaction;
}
+2
View File
@@ -11,6 +11,7 @@ import type {
VaultState,
ViewMode,
SortMode,
TagStyle,
} from "$lib/types";
// App state
@@ -47,6 +48,7 @@ export const showSettings = writable(false);
export const settingsTab = writable<string | null>(null);
export const showInfo = writable(false);
export const notebookIcons = writable<Record<string, string>>({});
export const tagStyles = writable<Record<string, TagStyle>>({});
export const quickAccessPaths = writable<string[]>([]);
export const collapsedNotebooks = writable<string[]>([]);
+6
View File
@@ -34,6 +34,11 @@ export interface NotebookEntry {
note_count: number;
}
export interface TagStyle {
icon?: string | null;
color?: string | null;
}
export interface NoteContent {
path: string;
meta: NoteMeta;
@@ -92,6 +97,7 @@ export interface AppConfig {
system_dark_theme: string;
accent_color: string | null;
font_size: number | null;
scroll_to_change_font_size: boolean;
font_family: string | null;
line_height: number | null;
ui_scale: number | null;
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const { getWheelFontSizeAction } = await import(
new URL('./editor-zoom.ts', import.meta.url)
);
test('blocks modified wheel input when scroll font sizing is disabled', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, false),
'block'
);
assert.equal(
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, false),
'block'
);
});
test('changes font size from modified wheel input when enabled', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, true),
'increase'
);
assert.equal(
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, true),
'decrease'
);
});
test('ignores unmodified wheel input', () => {
assert.equal(
getWheelFontSizeAction({ ctrlKey: false, metaKey: false, deltaY: -1 }, false),
'ignore'
);
});
+16
View File
@@ -0,0 +1,16 @@
export type WheelFontSizeAction = 'ignore' | 'block' | 'increase' | 'decrease';
interface WheelModifierInput {
ctrlKey: boolean;
metaKey: boolean;
deltaY: number;
}
export function getWheelFontSizeAction(
event: WheelModifierInput,
scrollToChangeFontSize: boolean,
): WheelFontSizeAction {
if (!event.ctrlKey && !event.metaKey) return 'ignore';
if (!scrollToChangeFontSize) return 'block';
return event.deltaY < 0 ? 'increase' : 'decrease';
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const {
applyCommittedTagStyle,
createTagStylePersister,
isValidTagColor,
lookupTagStyle,
nextTagStyle,
normalizeTagStyleKey,
tagColor,
tagCustomImagePath,
tagIterationKey,
tagStyleKeyEquals
} = await import(new URL('./tag-styles.ts', import.meta.url));
test('normalizes tag style keys by trimming', () => {
assert.equal(normalizeTagStyleKey(' work '), 'work');
});
test('accepts 3 and 6 digit hex colors only', () => {
assert.equal(isValidTagColor('#e11'), true);
assert.equal(isValidTagColor('#e11d48'), true);
assert.equal(isValidTagColor('#E11D48'), true);
assert.equal(isValidTagColor('red'), false);
assert.equal(isValidTagColor('#gg0000'), false);
assert.equal(isValidTagColor('#e11d48aa'), false);
});
test('looks up tag styles with a case-insensitive fallback', () => {
const styles = { Work: { icon: 'builtin:briefcase', color: '#e11d48' } };
assert.equal(lookupTagStyle('Work', styles)?.icon, 'builtin:briefcase');
assert.equal(lookupTagStyle('work', styles)?.color, '#e11d48');
assert.equal(tagColor('missing', styles), null);
assert.equal(tagStyleKeyEquals('Work', 'work'), true);
});
test('treats non-builtin icons as custom image paths', () => {
const styles = { daily: { icon: '.helixnotes/attachments/tag.png' } };
assert.equal(tagCustomImagePath('daily', styles), '.helixnotes/attachments/tag.png');
assert.equal(tagCustomImagePath('daily', { daily: { icon: 'builtin:calendar' } }), null);
});
test('merges style patches and clears empty styles', () => {
const current = { icon: 'builtin:star', color: '#3b82f6' };
assert.deepEqual(nextTagStyle(current, { color: '#22c55e' }), {
icon: 'builtin:star',
color: '#22c55e'
});
assert.equal(nextTagStyle(current, { icon: null, color: null }), null);
});
test('resetting a differently cased tag removes the stored style', () => {
const styles = { Work: { icon: 'builtin:briefcase', color: '#e11d48' } };
assert.deepEqual(applyCommittedTagStyle(styles, 'work', null), {});
});
test('saving a style replaces a differently cased stored key', () => {
const styles = { Work: { icon: 'builtin:briefcase' } };
const next = applyCommittedTagStyle(styles, 'work', {
icon: 'builtin:star',
color: '#ec4899'
});
assert.deepEqual(next, {
Work: { icon: 'builtin:star', color: '#ec4899' }
});
assert.equal(Object.hasOwn(next, 'work'), false);
});
test('queued icon then color patches keep both fields', async () => {
const writes = [];
let releaseFirst;
const firstWrite = new Promise((resolve) => {
releaseFirst = resolve;
});
const persister = createTagStylePersister({
getCurrent: () => undefined,
async save(next) {
writes.push(next);
if (writes.length === 1) await firstWrite;
}
});
assert.deepEqual(persister.apply({ icon: 'builtin:star' }), { icon: 'builtin:star' });
assert.deepEqual(persister.apply({ color: '#ec4899' }), {
icon: 'builtin:star',
color: '#ec4899'
});
releaseFirst();
await persister.flush();
assert.deepEqual(writes, [
{ icon: 'builtin:star' },
{ icon: 'builtin:star', color: '#ec4899' }
]);
});
test('queued reset after a pending save clears the stored style', async () => {
let current = { icon: 'builtin:star' };
const writes = [];
let releaseFirst;
const firstWrite = new Promise((resolve) => {
releaseFirst = resolve;
});
const persister = createTagStylePersister({
getCurrent: () => current,
async save(next) {
writes.push(next);
current = next ?? undefined;
if (writes.length === 1) await firstWrite;
}
});
persister.apply({ color: '#8b5cf6' });
assert.equal(persister.apply({ icon: null, color: null }), null);
releaseFirst();
await persister.flush();
assert.deepEqual(writes, [
{ icon: 'builtin:star', color: '#8b5cf6' },
null
]);
assert.deepEqual(applyCommittedTagStyle({ work: writes[0] }, 'Work', writes[1]), {});
});
test('duplicate tag names still get unique each keys', () => {
const tags = ['work', 'work'];
const keys = tags.map((tag, index) => tagIterationKey(tag, index));
assert.deepEqual(keys, ['0:work', '1:work']);
assert.equal(new Set(keys).size, keys.length);
});
+126
View File
@@ -0,0 +1,126 @@
export interface TagStyleFields {
icon?: string | null;
color?: string | null;
}
export const TAG_COLOR_PRESETS = [
'#e11d48',
'#f97316',
'#eab308',
'#22c55e',
'#14b8a6',
'#3b82f6',
'#8b5cf6',
'#ec4899',
'#64748b'
];
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const BUILTIN_PREFIX = 'builtin:';
export function normalizeTagStyleKey(tag: string): string {
return tag.trim();
}
export function isValidTagColor(value: string | null | undefined): value is string {
return typeof value === 'string' && HEX_COLOR.test(value);
}
export function tagStyleKeyEquals(a: string, b: string): boolean {
return normalizeTagStyleKey(a).toLowerCase() === normalizeTagStyleKey(b).toLowerCase();
}
export function existingTagStyleKey(
tag: string,
styles: Record<string, TagStyleFields>
): string | undefined {
const key = normalizeTagStyleKey(tag);
if (!key) return undefined;
if (Object.hasOwn(styles, key)) return key;
const lower = key.toLowerCase();
return Object.keys(styles).find((name) => name.toLowerCase() === lower);
}
export function lookupTagStyle(
tag: string,
styles: Record<string, TagStyleFields>
): TagStyleFields | undefined {
const storedKey = existingTagStyleKey(tag, styles);
return storedKey ? styles[storedKey] : undefined;
}
/** Stable `{#each}` key when a note can contain duplicate tag names. */
export function tagIterationKey(tag: string, index: number): string {
return `${index}:${tag}`;
}
export function tagColor(
tag: string,
styles: Record<string, TagStyleFields>
): string | null {
const color = lookupTagStyle(tag, styles)?.color;
return isValidTagColor(color) ? color : null;
}
export function tagCustomImagePath(
tag: string,
styles: Record<string, TagStyleFields>
): string | null {
const icon = lookupTagStyle(tag, styles)?.icon;
if (!icon || icon.startsWith(BUILTIN_PREFIX)) return null;
return icon;
}
export function nextTagStyle(
current: TagStyleFields | undefined,
patch: TagStyleFields
): TagStyleFields | null {
const icon = patch.icon === undefined ? current?.icon : patch.icon;
const color = patch.color === undefined ? current?.color : patch.color;
const next: TagStyleFields = {};
if (typeof icon === 'string' && icon.trim()) next.icon = icon;
if (isValidTagColor(color)) next.color = color;
return next.icon || next.color ? next : null;
}
export function applyCommittedTagStyle(
styles: Record<string, TagStyleFields>,
tag: string,
next: TagStyleFields | null
): Record<string, TagStyleFields> {
const result = { ...styles };
const key = normalizeTagStyleKey(tag);
if (!key) return result;
const lower = key.toLowerCase();
for (const name of Object.keys(result)) {
if (name.toLowerCase() === lower) delete result[name];
}
if (next) {
const storedKey = existingTagStyleKey(tag, styles) ?? key;
result[storedKey] = next;
}
return result;
}
export function createTagStylePersister(options: {
getCurrent: () => TagStyleFields | undefined;
save: (next: TagStyleFields | null) => Promise<void>;
}) {
let draft: TagStyleFields | null | undefined;
let queue = Promise.resolve();
return {
apply(patch: TagStyleFields): TagStyleFields | null {
const current = draft === undefined ? options.getCurrent() : (draft ?? undefined);
draft = nextTagStyle(current, patch);
const snapshot = draft;
const write = () => options.save(snapshot);
queue = queue.then(write, write);
return snapshot;
},
flush() {
return queue;
}
};
}
+13 -2
View File
@@ -3,6 +3,7 @@
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes, isIOS, isMobile } from '$lib/platform';
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -16,6 +17,7 @@
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null;
let removeFontSizeListener: (() => void) | null = null;
let removeScrollFontSizeListener: (() => void) | null = null;
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
let startupWindowRevealed = false;
@@ -121,10 +123,15 @@
};
const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
const action = getWheelFontSizeAction(
event,
$appConfig?.scroll_to_change_font_size ?? true
);
if (action === 'ignore') return;
event.preventDefault();
event.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1);
if (action === 'increase') zoomEditor(1);
else if (action === 'decrease') zoomEditor(-1);
};
window.addEventListener('keydown', handleKeydown);
@@ -153,6 +160,9 @@
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
});
removeScrollFontSizeListener = await listen<boolean>('scroll-to-change-font-size-changed', (event) => {
if ($appConfig) $appConfig.scroll_to_change_font_size = event.payload;
});
$theme = config.theme || 'system';
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
@@ -287,6 +297,7 @@
onDestroy(() => {
removeEditorZoomShortcuts?.();
removeFontSizeListener?.();
removeScrollFontSizeListener?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
if (startupRevealTimer) clearTimeout(startupRevealTimer);
});
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { createContext, Script } from 'node:vm';
import { compile, parse } from 'svelte/compiler';
import { get, writable } from 'svelte/store';
const source = await readFile(
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
'utf8'
);
const ast = parse(source, { modern: true });
function elementWithClass(nodes, className) {
return nodes.find((node) => node.attributes?.some((attribute) =>
attribute.name === 'class' && attribute.value[0]?.data === className
));
}
const aside = elementWithClass(ast.fragment.nodes, 'sidebar');
const blocks = aside.fragment.nodes.filter((node) => node.type === 'IfBlock');
const content = blocks.find((node) => elementWithClass(node.consequent.nodes, 'section'));
const header = blocks.find((node) => elementWithClass(node.consequent.nodes, 'sidebar-header'));
const toggle = elementWithClass(
elementWithClass(header.consequent.nodes, 'sidebar-header').fragment.nodes,
'collapse-btn'
);
function expressionScript(expression) {
assert.ok(expression, 'expected a component expression');
return new Script(`(${source.slice(expression.start, expression.end)})`);
}
// Execute the actual template conditions, not a copy of the collapse logic.
const expressions = {
contentVisible: expressionScript(content.test),
collapsedClass: expressionScript(aside.attributes.find((attribute) =>
attribute.type === 'ClassDirective' && attribute.name === 'collapsed'
).expression),
toggleVisible: expressionScript(header.test)
};
const toggleScript = expressionScript(
toggle.attributes.find((attribute) => attribute.name === 'onclick').value.expression
);
function sidebarContext(isMobile, stored) {
const sidebarCollapsed = writable(stored);
const context = createContext({
isMobile,
get $sidebarCollapsed() { return get(sidebarCollapsed); },
set $sidebarCollapsed(value) { sidebarCollapsed.set(value); }
});
return { context, sidebarCollapsed };
}
function evaluate(context) {
return Object.fromEntries(Object.entries(expressions).map(([name, script]) =>
[name, script.runInContext(context)]
));
}
const scenarios = [
{
platform: 'desktop', isMobile: false,
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: true },
collapsed: { contentVisible: false, collapsedClass: true, toggleVisible: true }
},
{
platform: 'mobile', isMobile: true,
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: false },
collapsed: { contentVisible: true, collapsedClass: false, toggleVisible: false }
}
];
for (const scenario of scenarios) {
for (const stored of [false, true]) {
test(`${scenario.platform} navigation with stored sidebar_collapsed=${stored}`, () => {
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, stored);
assert.deepEqual(evaluate(context), stored ? scenario.collapsed : scenario.expanded);
assert.equal(get(sidebarCollapsed), stored, 'rendering must preserve the stored preference');
});
}
test(`${scenario.platform} navigation follows late false -> true -> false store updates`, () => {
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, false);
assert.deepEqual(evaluate(context), scenario.expanded);
sidebarCollapsed.set(true);
assert.deepEqual(evaluate(context), scenario.collapsed);
assert.equal(get(sidebarCollapsed), true, 'rendering must not reset the loaded preference');
sidebarCollapsed.set(false);
assert.deepEqual(evaluate(context), scenario.expanded);
assert.equal(get(sidebarCollapsed), false);
});
}
test('desktop collapse toggle still collapses and expands the sidebar', () => {
const { context, sidebarCollapsed } = sidebarContext(false, false);
const toggleSidebar = toggleScript.runInContext(context);
toggleSidebar();
assert.equal(get(sidebarCollapsed), true);
assert.deepEqual(evaluate(context), scenarios[0].collapsed);
toggleSidebar();
assert.equal(get(sidebarCollapsed), false);
assert.deepEqual(evaluate(context), scenarios[0].expanded);
});
test('Sidebar compiles for the client', () => {
assert.doesNotThrow(() => compile(source, {
filename: 'src/lib/components/Sidebar.svelte',
generate: 'client'
}));
});
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = await readFile(
new URL('../src/lib/components/SettingsPanel.svelte', import.meta.url),
'utf8'
);
const initialization = source.match(
/(const\s+themePresets\s*=\s*\[[\s\S]*?\];)[\s\S]*?(?=\bconst\s+accentPresets\s*=)/
);
assert.ok(initialization, 'theme presets initialization was not found');
// Evaluate only the trusted repository declaration and its initialization code.
const unsorted = new Function(`${initialization[1]}\nreturn themePresets;`)();
const presets = new Function(`${initialization[0]}\nreturn themePresets;`)();
test('pins System, Light, and Dark in that exact order', () => {
assert.deepEqual(presets.slice(0, 3).map(({ id, label }) => ({ id, label })), [
{ id: 'system', label: 'System' },
{ id: 'light', label: 'Light' },
{ id: 'dark', label: 'Dark' }
]);
});
test('orders remaining built-in labels alphabetically, including Rosé Pine', () => {
const labels = presets.slice(3).map((preset) => preset.label);
assert.ok(labels.includes('Rosé Pine'));
assert.deepEqual(labels, [...labels].sort());
});
test('preserves every theme ID, label, and color without duplicates', () => {
assert.equal(presets.length, unsorted.length);
for (const themes of [unsorted, presets]) {
assert.equal(new Set(themes.map((preset) => preset.id)).size, themes.length);
assert.equal(new Set(themes.map((preset) => preset.label)).size, themes.length);
}
assert.deepEqual(
Object.fromEntries(presets.map((preset) => [preset.id, preset])),
Object.fromEntries(unsorted.map((preset) => [preset.id, preset]))
);
});
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const { restoreTitleHeading, stripTitleHeading } = await import(
new URL('../src/lib/editor/titleVisibility.ts', import.meta.url)
);
const editorSource = await readFile(
new URL('../src/lib/components/Editor.svelte', import.meta.url),
'utf8'
);
test('rich to source transition strips the restored title before display', () => {
const start = editorSource.indexOf('if (isSource && !lastSourceMode) {');
const end = editorSource.indexOf('} else if (!isSource && lastSourceMode)', start);
assert.notEqual(start, -1, 'rich to source transition was not found');
assert.notEqual(end, -1, 'rich to source transition boundary was not found');
assert.match(
editorSource.slice(start, end),
/sourceContent\s*=\s*stripTitleH1\(\s*editor\s*\?\s*editorToMarkdown\(\)\s*:\s*\(\$activeNote\?\.content\s*\?\?\s*''\)\s*\)\s*;/
);
});
test('source to rich transition preserves the hidden title on desktop and mobile', () => {
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
const transition = editorSource.slice(start, end);
assert.notEqual(start, -1, 'source to rich transition was not found');
assert.notEqual(end, -1, 'source to rich transition boundary was not found');
assert.match(
transition,
/editor\.commands\.setContent\(\s*markdownToHtml\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*\)\s*;/
);
assert.match(transition, /createEditor\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*;/);
});
test('source to rich transition preserves an empty source body on desktop and mobile', () => {
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
const transition = editorSource.slice(start, end);
const contentAssignments = [...transition.matchAll(/const content = ([^;]+);/g)]
.map((match) => match[1].trim());
assert.deepEqual(contentAssignments, ['srcText', 'srcText']);
assert.doesNotMatch(transition, /srcText\s*\|\|/);
});
test('keeps a hidden title through source to rich to source and save', () => {
const persisted = '# Note title\n\n## Something else\n\nBody\n';
const body = '## Something else\n\nBody\n';
const initialSource = stripTitleHeading(persisted, 'Note title', true);
const rich = stripTitleHeading(
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
'Note title',
true
);
const toggledSource = stripTitleHeading(
restoreTitleHeading(rich.markdown, rich.hiddenTitle),
'Note title',
true
);
assert.equal(rich.markdown, body);
assert.equal(toggledSource.markdown, body);
assert.equal(restoreTitleHeading(toggledSource.markdown, toggledSource.hiddenTitle), persisted);
});
test('keeps a title-only note visually empty and saves one title heading', () => {
const initialSource = stripTitleHeading('# Note title\n', 'Note title', true);
const rich = stripTitleHeading(
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
'Note title',
true
);
const saved = restoreTitleHeading(rich.markdown, rich.hiddenTitle);
assert.equal(initialSource.markdown, '');
assert.equal(rich.markdown, '');
assert.equal((saved.match(/^# Note title$/gm) ?? []).length, 1);
});
test('replaces hidden title state when an unrelated note loads', () => {
const markdown = '## Something else\n\nOther body\n';
const start = editorSource.indexOf('function stripTitleH1(md: string): string {');
const end = editorSource.indexOf('function restoreTitleH1(md: string): string {', start);
assert.deepEqual(stripTitleHeading(markdown, 'Another note', true), {
markdown,
hiddenTitle: null
});
assert.match(editorSource.slice(start, end), /hiddenTitleHeading\s*=\s*result\.hiddenTitle\s*;/);
});
test('leaves the title visible when title hiding is disabled', () => {
const persisted = '# Note title\n\n## Something else\n';
assert.deepEqual(stripTitleHeading(persisted, 'Note title', false), {
markdown: persisted,
hiddenTitle: null
});
});