mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-22 19:07:34 +02:00
Compare commits
19
Commits
221625aabd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3607cf1b3f | ||
|
|
245cdacb68 | ||
|
|
2983e3db29 | ||
|
|
cc7c9e2b49 | ||
|
|
2240231f61 | ||
|
|
b2d0c7175e | ||
|
|
1bc0026731 | ||
|
|
41c0e4c993 | ||
|
|
34a0f9efa3 | ||
|
|
9d92489586 | ||
|
|
c45efb60ab | ||
|
|
0d11fd01dd | ||
|
|
0aaa05ce5c | ||
|
|
77e92ef25c | ||
|
|
d7e4be7f3a | ||
|
|
2aa572dcaa | ||
|
|
32f270ce9e | ||
|
|
a15b267614 | ||
|
|
ce48d77cd8 |
@@ -1,7 +1,7 @@
|
|||||||
# HelixNotes
|
# HelixNotes
|
||||||
|
|
||||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
|
||||||
[](https://helixnotes.com)
|
[](https://helixnotes.com)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
|||||||
Your notes are stored as standard Markdown files on your local filesystem.
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
No cloud, no lock-in.
|
No cloud, no lock-in.
|
||||||
|
|
||||||
## Download (v1.3.4)
|
## Download (v1.3.5)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ No cloud, no lock-in.
|
|||||||
|
|
||||||
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
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
|
#### Distro-specific packages
|
||||||
|
|
||||||
@@ -108,26 +108,28 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
#### Manual package downloads
|
#### Manual package downloads
|
||||||
|
|
||||||
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
- [.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.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
- [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
|
||||||
|
|
||||||
### Windows
|
### 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
|
### 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):
|
[Download .dmg (Intel)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64.dmg)
|
||||||
>
|
|
||||||
> ```bash
|
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:
|
||||||
> xattr -cr /Applications/HelixNotes.app
|
|
||||||
> ```
|
```bash
|
||||||
|
xattr -cr /Applications/HelixNotes.app
|
||||||
|
```
|
||||||
|
|
||||||
### Android
|
### 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
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.3.4",
|
"version": "1.3.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
|
||||||
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
"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",
|
"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",
|
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1776,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.4"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.3.4"
|
version = "1.3.5"
|
||||||
description = "Local markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|||||||
@@ -470,6 +470,22 @@ pub fn set_font_size(app: AppHandle, state: State<'_, AppState>, size: u32) -> R
|
|||||||
.map_err(|e| e.to_string())
|
.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]
|
#[tauri::command]
|
||||||
pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(), String> {
|
pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(), String> {
|
||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -1572,6 +1588,28 @@ pub fn set_notebook_icon(
|
|||||||
operations::set_notebook_icon(vault_path, ¬ebook_relative, icon_relative.as_deref())
|
operations::set_notebook_icon(vault_path, ¬ebook_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 ──
|
// ── General Settings ──
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1787,7 +1825,7 @@ pub struct OrphanAttachment {
|
|||||||
|
|
||||||
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
|
// 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)
|
// .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).
|
// 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.
|
// 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> {
|
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
|
||||||
@@ -1825,12 +1863,14 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Folder icons live in attachments but are referenced here, not in notes. (#157)
|
// Folder and tag icons live in attachments but are referenced here, not in notes. (#157)
|
||||||
let icons_path = operations::helixnotes_dir(vault).join("notebook_icons.json");
|
for mapping in ["notebook_icons.json", "tag_styles.json"] {
|
||||||
if let Ok(content) = std::fs::read_to_string(&icons_path) {
|
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_str(&content);
|
||||||
haystack.push('\n');
|
haystack.push('\n');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let decoded = percent_decode(&haystack);
|
let decoded = percent_decode(&haystack);
|
||||||
let orphans = files
|
let orphans = files
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ pub fn run() {
|
|||||||
commands::export_custom_theme,
|
commands::export_custom_theme,
|
||||||
commands::import_custom_themes,
|
commands::import_custom_themes,
|
||||||
commands::set_font_size,
|
commands::set_font_size,
|
||||||
|
commands::set_scroll_to_change_font_size,
|
||||||
commands::set_font_family,
|
commands::set_font_family,
|
||||||
commands::set_line_height,
|
commands::set_line_height,
|
||||||
commands::set_ui_scale,
|
commands::set_ui_scale,
|
||||||
@@ -215,6 +216,8 @@ pub fn run() {
|
|||||||
commands::save_attachment,
|
commands::save_attachment,
|
||||||
commands::get_notebook_icons,
|
commands::get_notebook_icons,
|
||||||
commands::set_notebook_icon,
|
commands::set_notebook_icon,
|
||||||
|
commands::get_tag_styles,
|
||||||
|
commands::set_tag_style,
|
||||||
commands::set_general_settings,
|
commands::set_general_settings,
|
||||||
commands::get_quick_access,
|
commands::get_quick_access,
|
||||||
commands::add_quick_access,
|
commands::add_quick_access,
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||||
// conflict copies so nothing is ever lost.
|
// conflict copies so nothing is ever lost.
|
||||||
//
|
//
|
||||||
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`,
|
||||||
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
// `.helixnotes/notebook_icons.json`, and `.helixnotes/tag_styles.json`. Search indexes,
|
||||||
// and the manifest itself remain local-only.
|
// trash, history, other metadata, and the manifest itself remain local-only.
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::vault::operations::helixnotes_dir;
|
use crate::vault::operations::helixnotes_dir;
|
||||||
@@ -123,11 +123,13 @@ struct LocalFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
/// 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.
|
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||||
fn is_synced_relpath(rel: &str) -> bool {
|
fn is_synced_relpath(rel: &str) -> bool {
|
||||||
if rel.starts_with(".helixnotes/") {
|
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 {
|
} else {
|
||||||
rel.ends_with(".md")
|
rel.ends_with(".md")
|
||||||
}
|
}
|
||||||
@@ -758,6 +760,7 @@ mod tests {
|
|||||||
"Notes/plan.md",
|
"Notes/plan.md",
|
||||||
".helixnotes/attachments/notebook-icon.png",
|
".helixnotes/attachments/notebook-icon.png",
|
||||||
".helixnotes/notebook_icons.json",
|
".helixnotes/notebook_icons.json",
|
||||||
|
".helixnotes/tag_styles.json",
|
||||||
] {
|
] {
|
||||||
assert!(is_synced_relpath(path), "expected {path} to be synced");
|
assert!(is_synced_relpath(path), "expected {path} to be synced");
|
||||||
}
|
}
|
||||||
@@ -766,6 +769,7 @@ mod tests {
|
|||||||
"Notes/image.png",
|
"Notes/image.png",
|
||||||
".helixnotes/sync_state.json",
|
".helixnotes/sync_state.json",
|
||||||
".helixnotes/notebook_icons.json.bak",
|
".helixnotes/notebook_icons.json.bak",
|
||||||
|
".helixnotes/tag_styles.json.bak",
|
||||||
".helixnotes/attachments-old/icon.png",
|
".helixnotes/attachments-old/icon.png",
|
||||||
] {
|
] {
|
||||||
assert!(!is_synced_relpath(path), "expected {path} to stay local");
|
assert!(!is_synced_relpath(path), "expected {path} to stay local");
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ pub struct NotebookEntry {
|
|||||||
pub note_count: usize,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NoteContent {
|
pub struct NoteContent {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -124,6 +146,8 @@ pub struct AppConfig {
|
|||||||
pub accent_color: Option<String>,
|
pub accent_color: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub font_size: Option<u32>,
|
pub font_size: Option<u32>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub scroll_to_change_font_size: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub font_family: Option<String>,
|
pub font_family: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -296,6 +320,7 @@ impl Default for AppConfig {
|
|||||||
system_dark_theme: default_system_dark_theme(),
|
system_dark_theme: default_system_dark_theme(),
|
||||||
accent_color: None,
|
accent_color: None,
|
||||||
font_size: None,
|
font_size: None,
|
||||||
|
scroll_to_change_font_size: true,
|
||||||
font_family: None,
|
font_family: None,
|
||||||
line_height: None,
|
line_height: None,
|
||||||
ui_scale: None,
|
ui_scale: None,
|
||||||
@@ -554,4 +579,19 @@ mod startup_view_tests {
|
|||||||
|
|
||||||
assert!(!config.show_note_switcher);
|
assert!(!config.show_note_switcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
assert!(config.scroll_to_change_font_size);
|
||||||
|
|
||||||
|
let mut value = serde_json::to_value(config).unwrap();
|
||||||
|
value
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("scroll_to_change_font_size");
|
||||||
|
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||||
|
|
||||||
|
assert!(config.scroll_to_change_font_size);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::types::{
|
use crate::types::{
|
||||||
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
|
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TagStyle, TrashContents,
|
||||||
TrashNotebookEntry, VaultState,
|
TrashNotebookEntry, VaultState,
|
||||||
};
|
};
|
||||||
use crate::vault::frontmatter;
|
use crate::vault::frontmatter;
|
||||||
@@ -1448,6 +1448,100 @@ pub fn set_notebook_icon(
|
|||||||
Ok(())
|
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> {
|
pub fn load_quick_access(vault_path: &str) -> Result<Vec<String>, String> {
|
||||||
let qa_path = helixnotes_dir(vault_path).join("quick_access.json");
|
let qa_path = helixnotes_dir(vault_path).join("quick_access.json");
|
||||||
if qa_path.exists() {
|
if qa_path.exists() {
|
||||||
@@ -1566,9 +1660,10 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
|
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
|
||||||
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
|
helixnotes_dir, load_notebook_icons, load_tag_styles, permanent_delete, read_note,
|
||||||
scan_notebooks, set_notebook_icon,
|
restore_notebook, scan_notebooks, set_notebook_icon, set_tag_style,
|
||||||
};
|
};
|
||||||
|
use crate::types::TagStyle;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -1660,6 +1755,144 @@ mod tests {
|
|||||||
fs::remove_dir_all(vault).unwrap();
|
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]
|
#[test]
|
||||||
fn loads_only_requested_note_switcher_titles() {
|
fn loads_only_requested_note_switcher_titles() {
|
||||||
let vault =
|
let vault =
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.3.4",
|
"version": "1.3.5",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
|
"title": "HelixNotes",
|
||||||
"label": "main",
|
"label": "main",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 500,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"decorations": false,
|
||||||
"visible": false
|
"visible": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
TrashContents,
|
TrashContents,
|
||||||
VaultState,
|
VaultState,
|
||||||
VaultStats,
|
VaultStats,
|
||||||
|
TagStyle,
|
||||||
ImportResult,
|
ImportResult,
|
||||||
BackupEntry,
|
BackupEntry,
|
||||||
VersionEntry,
|
VersionEntry,
|
||||||
@@ -71,6 +72,10 @@ export async function setFontSize(size: number): Promise<void> {
|
|||||||
return invoke("set_font_size", { size });
|
return invoke("set_font_size", { size });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
|
||||||
|
return invoke("set_scroll_to_change_font_size", { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
export async function setFontFamily(family: string): Promise<void> {
|
export async function setFontFamily(family: string): Promise<void> {
|
||||||
return invoke("set_font_family", { family });
|
return invoke("set_font_family", { family });
|
||||||
}
|
}
|
||||||
@@ -265,6 +270,17 @@ export async function setNotebookIcon(
|
|||||||
return invoke("set_notebook_icon", { notebookRelative, iconRelative });
|
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(
|
export async function setGeneralSettings(
|
||||||
compactNotes: boolean,
|
compactNotes: boolean,
|
||||||
timeFormat: string,
|
timeFormat: string,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import InfoPanel from './InfoPanel.svelte';
|
import InfoPanel from './InfoPanel.svelte';
|
||||||
import TitleBar from './TitleBar.svelte';
|
import TitleBar from './TitleBar.svelte';
|
||||||
import ResizeHandle from './ResizeHandle.svelte';
|
import ResizeHandle from './ResizeHandle.svelte';
|
||||||
|
import TagLabel from './TagLabel.svelte';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import {
|
import {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
@@ -578,6 +579,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
|
||||||
|
if (!dismissesAppUi) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if ($showSettings) $showSettings = false;
|
if ($showSettings) $showSettings = false;
|
||||||
else if ($showInfo) $showInfo = false;
|
else if ($showInfo) $showInfo = false;
|
||||||
else if ($focusMode) $focusMode = false;
|
else if ($focusMode) $focusMode = false;
|
||||||
@@ -850,7 +856,7 @@
|
|||||||
{#if $mobileView === 'sidebar'}
|
{#if $mobileView === 'sidebar'}
|
||||||
HelixNotes
|
HelixNotes
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
|
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
|
||||||
@@ -1240,6 +1246,9 @@
|
|||||||
|
|
||||||
.mobile-header-title {
|
.mobile-header-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
@@ -164,10 +164,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleEscape(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key !== 'Escape') return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
$showCommandPalette = false;
|
$showCommandPalette = false;
|
||||||
} else if (e.key === 'ArrowDown') {
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
@@ -194,7 +199,7 @@
|
|||||||
|
|
||||||
{#if $showCommandPalette}
|
{#if $showCommandPalette}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||||
<div class="palette-input-wrapper">
|
<div class="palette-input-wrapper">
|
||||||
|
|||||||
@@ -54,9 +54,13 @@
|
|||||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||||
import { clearFormatting } from '$lib/editor/clearFormatting';
|
import { clearFormatting } from '$lib/editor/clearFormatting';
|
||||||
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
||||||
|
import { 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 { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
|
import TagLabel from './TagLabel.svelte';
|
||||||
import ImageViewer from './ImageViewer.svelte';
|
import ImageViewer from './ImageViewer.svelte';
|
||||||
import { isMobile, isAndroid } from '$lib/platform';
|
import { isMobile, isAndroid } from '$lib/platform';
|
||||||
import ResizeHandle from './ResizeHandle.svelte';
|
import ResizeHandle from './ResizeHandle.svelte';
|
||||||
@@ -116,9 +120,7 @@
|
|||||||
let hasPendingBlobs = false;
|
let hasPendingBlobs = false;
|
||||||
let lastSourceMode = $sourceMode;
|
let lastSourceMode = $sourceMode;
|
||||||
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
|
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
|
||||||
let titleWasStripped = false;
|
let hiddenTitleHeading: HiddenTitleHeading | null = null;
|
||||||
let strippedTitle = '';
|
|
||||||
let strippedHeadingPrefix = '';
|
|
||||||
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let taskRevealElement: HTMLElement | null = null;
|
let taskRevealElement: HTMLElement | null = null;
|
||||||
let taskRevealRequest = 0;
|
let taskRevealRequest = 0;
|
||||||
@@ -2547,10 +2549,7 @@
|
|||||||
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
|
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
|
||||||
if (!editor || !wikiLinkMenu) return;
|
if (!editor || !wikiLinkMenu) return;
|
||||||
const { from } = wikiLinkMenu;
|
const { from } = wikiLinkMenu;
|
||||||
// Delete the [[ trigger and query text
|
|
||||||
const to = editor.state.selection.from;
|
const to = editor.state.selection.from;
|
||||||
editor.chain().focus().deleteRange({ from, to }).run();
|
|
||||||
// Insert the wiki-link mark
|
|
||||||
// For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
|
// For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
|
||||||
const displayText = entry.title;
|
const displayText = entry.title;
|
||||||
let titleAttr = originalRef || entry.title;
|
let titleAttr = originalRef || entry.title;
|
||||||
@@ -2562,17 +2561,16 @@
|
|||||||
titleAttr = relPath + anchor;
|
titleAttr = relPath + anchor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tick().then(() => {
|
const transaction = replaceWithWikiLink(
|
||||||
if (!editor) return;
|
editor.state,
|
||||||
editor.chain().focus()
|
from,
|
||||||
.insertContent({
|
to,
|
||||||
type: 'text',
|
displayText,
|
||||||
text: displayText,
|
{ title: titleAttr, path: entry.path || '', aliased: displayText !== titleAttr },
|
||||||
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }],
|
);
|
||||||
})
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
closeWikiLinkMenu();
|
closeWikiLinkMenu();
|
||||||
|
editor.view.dispatch(transaction);
|
||||||
|
editor.view.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeWikiLinkCommand(index: number) {
|
function executeWikiLinkCommand(index: number) {
|
||||||
@@ -2759,21 +2757,13 @@
|
|||||||
closeWikiLinkMenu();
|
closeWikiLinkMenu();
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
// Use a single ProseMirror transaction to replace [[query] with the
|
editor.view.dispatch(replaceWithWikiLink(
|
||||||
// wiki-link and clear stored marks atomically, preventing the inclusive
|
editor.state,
|
||||||
// mark from bleeding into subsequent text. Do NOT call deleteRange first
|
menuFrom,
|
||||||
// — that would shift positions and make menuFrom/curTo invalid here.
|
curTo,
|
||||||
const { tr, schema } = editor.view.state;
|
display,
|
||||||
const wikiLinkMark = schema.marks.wikiLink.create({
|
{ title: noteRef, path: '', aliased: display !== noteRef },
|
||||||
title: noteRef,
|
));
|
||||||
path: '',
|
|
||||||
aliased: display !== noteRef,
|
|
||||||
});
|
|
||||||
const textNode = schema.text(display, [wikiLinkMark]);
|
|
||||||
tr.replaceWith(menuFrom, curTo, textNode);
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, menuFrom + display.length));
|
|
||||||
tr.setStoredMarks([]);
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3414,44 +3404,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stripTitleH1(md: string): string {
|
function stripTitleH1(md: string): string {
|
||||||
const title = $activeNote?.meta.title;
|
const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false);
|
||||||
if (!$appConfig?.hide_title_in_body || !title) {
|
hiddenTitleHeading = result.hiddenTitle;
|
||||||
titleWasStripped = false;
|
return result.markdown;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreTitleH1(md: string): string {
|
function restoreTitleH1(md: string): string {
|
||||||
if (!titleWasStripped || !strippedTitle) return md;
|
return restoreTitleHeading(md, hiddenTitleHeading);
|
||||||
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editorToMarkdown(): string {
|
function editorToMarkdown(): string {
|
||||||
@@ -5007,13 +4966,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ctxPaste() {
|
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 {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
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) {
|
} catch (e) {
|
||||||
console.error('Paste failed:', 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();
|
closeTextContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5640,18 +5611,21 @@
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// WebKitGTK fallback (bug #218519): older WebKitGTK versions return
|
// WebKitGTK fallback (bug #218519): WebKitGTK never exposes a clipboard image
|
||||||
// empty DataTransferItemList for image pastes. Detect this and read
|
// through DataTransfer, so the loop above cannot fire on Linux and this is the
|
||||||
// the image directly from the system clipboard via Rust/arboard.
|
// only path that can work there. The guard used to also require an empty
|
||||||
if (items.length === 0) {
|
// 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 hasText = event.clipboardData!.getData('text/plain');
|
||||||
const hasHtml = event.clipboardData!.getData('text/html');
|
const html = event.clipboardData!.getData('text/html');
|
||||||
if (!hasText && !hasHtml) {
|
const htmlIsBareImage = /^\s*(?:<meta[^>]*>\s*)?<img\b[^>]*>\s*$/i.test(html);
|
||||||
|
if (!hasText && (!html || htmlIsBareImage)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
insertClipboardImage();
|
insertClipboardImage();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5790,7 +5764,7 @@
|
|||||||
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
||||||
: 0;
|
: 0;
|
||||||
const docNonWs = docNonWhitespace();
|
const docNonWs = docNonWhitespace();
|
||||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? ''));
|
||||||
resetSourceHistory(sourceContent);
|
resetSourceHistory(sourceContent);
|
||||||
lastSourceMode = true;
|
lastSourceMode = true;
|
||||||
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
||||||
@@ -5817,20 +5791,20 @@
|
|||||||
};
|
};
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// Mobile: editor stays in DOM, just update its content
|
// Mobile: editor stays in DOM, just update its content
|
||||||
const content = srcText || ($activeNote?.content ?? '');
|
const content = srcText;
|
||||||
if (editor) {
|
if (editor) {
|
||||||
ignoreNextUpdate = true;
|
ignoreNextUpdate = true;
|
||||||
editor.commands.setContent(markdownToHtml(content));
|
editor.commands.setContent(markdownToHtml(restoreTitleH1(content)));
|
||||||
tick().then(restoreRichCaret);
|
tick().then(restoreRichCaret);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Desktop: destroy old editor (its DOM element is gone),
|
// Desktop: destroy old editor (its DOM element is gone),
|
||||||
// wait for DOM to swap textarea→div, then create editor on new element.
|
// wait for DOM to swap textarea→div, then create editor on new element.
|
||||||
destroyEditor();
|
destroyEditor();
|
||||||
const content = srcText || ($activeNote?.content ?? '');
|
const content = srcText;
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (editorElement && !editor) {
|
if (editorElement && !editor) {
|
||||||
createEditor(content);
|
createEditor(restoreTitleH1(content));
|
||||||
restoreRichCaret();
|
restoreRichCaret();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -5963,7 +5937,7 @@
|
|||||||
const oldPath = $activeNotePath;
|
const oldPath = $activeNotePath;
|
||||||
$activeNote.meta.title = newTitle;
|
$activeNote.meta.title = newTitle;
|
||||||
// Update stripped title so restoreTitleH1 uses the new title
|
// Update stripped title so restoreTitleH1 uses the new title
|
||||||
if (titleWasStripped) strippedTitle = newTitle;
|
if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle };
|
||||||
$editorDirty = true;
|
$editorDirty = true;
|
||||||
// Force save current editor content before renaming so disk is up-to-date
|
// Force save current editor content before renaming so disk is up-to-date
|
||||||
await forceSave();
|
await forceSave();
|
||||||
@@ -6164,7 +6138,7 @@
|
|||||||
<span class="meta-divider">·</span>
|
<span class="meta-divider">·</span>
|
||||||
<button class="note-tags-trigger" onclick={toggleTagMenu} title="Edit tags">
|
<button class="note-tags-trigger" onclick={toggleTagMenu} title="Edit tags">
|
||||||
{#if $activeNote.meta.tags?.length > 0}
|
{#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}
|
{:else}
|
||||||
<span class="note-tags-add">+ Tags</span>
|
<span class="note-tags-add">+ Tags</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -6489,8 +6463,8 @@
|
|||||||
<div class="info-row info-row-tags">
|
<div class="info-row info-row-tags">
|
||||||
<span class="info-key">Tags</span>
|
<span class="info-key">Tags</span>
|
||||||
<span class="info-value info-tags">
|
<span class="info-value info-tags">
|
||||||
{#each $activeNote.meta.tags as tag}
|
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}
|
||||||
<span class="info-tag">#{tag}</span>
|
<TagLabel name={tag} size={11} tone="muted" />
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -7487,9 +7461,9 @@
|
|||||||
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
|
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
|
||||||
{#if $activeNote.meta.tags.length > 0}
|
{#if $activeNote.meta.tags.length > 0}
|
||||||
<div class="tag-menu-list">
|
<div class="tag-menu-list">
|
||||||
{#each $activeNote.meta.tags as tag}
|
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}
|
||||||
<span class="tag-menu-chip">
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -8046,15 +8020,6 @@
|
|||||||
cursor: pointer;
|
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 {
|
.note-tags-add {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -11721,14 +11686,6 @@
|
|||||||
justify-content: flex-end;
|
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 {
|
.editor-container.mobile .editor-body-row:has(.info-panel) > .editor-body {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,13 @@
|
|||||||
if (event.target === event.currentTarget) close();
|
if (event.target === event.currentTarget) close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
function openLink(url: string) {
|
function openLink(url: string) {
|
||||||
openUrl(url).catch(console.error);
|
openUrl(url).catch(console.error);
|
||||||
}
|
}
|
||||||
@@ -104,7 +111,7 @@
|
|||||||
|
|
||||||
{#if $showInfo}
|
{#if $showInfo}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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-panel">
|
||||||
<div class="info-header">
|
<div class="info-header">
|
||||||
<h2>Info</h2>
|
<h2>Info</h2>
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||||
|
import { tagIterationKey } from '$lib/utils/tag-styles';
|
||||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||||
import TasksView from './TasksView.svelte';
|
import TasksView from './TasksView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
|
import TagLabel from './TagLabel.svelte';
|
||||||
import { isMobile, isAndroid } from '$lib/platform';
|
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) => {} }: {
|
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="note-list" class:mobile={isMobile}>
|
||||||
<div class="list-header">
|
<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">
|
<div class="list-actions">
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
|
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
|
||||||
@@ -1133,9 +1141,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if tagEditTags.length > 0}
|
{#if tagEditTags.length > 0}
|
||||||
<div class="tag-edit-list">
|
<div class="tag-edit-list">
|
||||||
{#each tagEditTags as tag}
|
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
|
||||||
<div class="tag-edit-item">
|
<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">
|
<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>
|
<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>
|
</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 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}
|
{#if note.meta.tags.length > 0}
|
||||||
<span class="note-tags">
|
<span class="note-tags">
|
||||||
{#each note.meta.tags.slice(0, 3) as tag}
|
{#each note.meta.tags.slice(0, 3) as tag, i (tagIterationKey(tag, i))}
|
||||||
<span class="mini-tag">#{tag}</span>
|
<TagLabel name={tag} size={10} tone="accent" />
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1445,9 +1453,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if tagEditTags.length > 0}
|
{#if tagEditTags.length > 0}
|
||||||
<div class="tag-edit-list">
|
<div class="tag-edit-list">
|
||||||
{#each tagEditTags as tag}
|
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
|
||||||
<div class="tag-edit-item">
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -1497,9 +1505,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if tagEditTags.length > 0}
|
{#if tagEditTags.length > 0}
|
||||||
<div class="tag-edit-list">
|
<div class="tag-edit-list">
|
||||||
{#each tagEditTags as tag}
|
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
|
||||||
<div class="tag-edit-item">
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -1683,6 +1691,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-title {
|
.list-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -2018,14 +2030,6 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-tag {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-accent);
|
|
||||||
background: var(--accent-light);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename-input {
|
.rename-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -2290,7 +2294,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .mini-tag {
|
.note-list.mobile :global(.tag-label.tone-accent) {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
activeNotePath,
|
activeNotePath,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
readOnly,
|
readOnly,
|
||||||
sourceMode
|
sourceMode,
|
||||||
|
tagStyles
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { readNote } from '$lib/api';
|
import { getTagStyles, readNote } from '$lib/api';
|
||||||
import { keybindings, matchAction } from '$lib/keybindings';
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
import type { FileEvent } from '$lib/types';
|
import type { FileEvent } from '$lib/types';
|
||||||
|
|
||||||
@@ -85,6 +86,12 @@
|
|||||||
});
|
});
|
||||||
await applyUiScale($appConfig?.ui_scale ?? 1);
|
await applyUiScale($appConfig?.ui_scale ?? 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tagStyles = await getTagStyles();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load tag styles:', e);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await readNote(notePath);
|
const content = await readNote(notePath);
|
||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
|
|||||||
@@ -44,10 +44,15 @@
|
|||||||
if (item) item.scrollIntoView({ block: 'nearest' });
|
if (item) item.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleEscape(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key !== 'Escape') return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
$showSearch = false;
|
$showSearch = false;
|
||||||
} else if (e.key === 'ArrowDown') {
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||||
scrollToSelected();
|
scrollToSelected();
|
||||||
@@ -128,7 +133,7 @@
|
|||||||
|
|
||||||
{#if $showSearch}
|
{#if $showSearch}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||||
<div class="search-input-wrapper">
|
<div class="search-input-wrapper">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
@@ -521,6 +521,17 @@
|
|||||||
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
|
{ 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 = [
|
const accentPresets = [
|
||||||
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
|
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
|
||||||
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
|
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
|
||||||
@@ -583,6 +594,7 @@
|
|||||||
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
||||||
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
||||||
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
||||||
|
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
|
||||||
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
||||||
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
||||||
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
||||||
@@ -987,6 +999,13 @@
|
|||||||
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleScrollToChangeFontSize() {
|
||||||
|
scrollToChangeFontSize = !scrollToChangeFontSize;
|
||||||
|
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
|
||||||
|
setScrollToChangeFontSize(scrollToChangeFontSize)
|
||||||
|
.catch((e) => console.error('Failed to save scroll font size setting:', e));
|
||||||
|
}
|
||||||
|
|
||||||
function applyFontSize(size: number) {
|
function applyFontSize(size: number) {
|
||||||
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
||||||
}
|
}
|
||||||
@@ -1060,6 +1079,15 @@
|
|||||||
if (event.target === event.currentTarget) close();
|
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) {
|
function dismissRestoreConfirm(event: MouseEvent) {
|
||||||
if (event.target === event.currentTarget) restoreConfirm = null;
|
if (event.target === event.currentTarget) restoreConfirm = null;
|
||||||
}
|
}
|
||||||
@@ -1080,6 +1108,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
|
||||||
const savedSize = $appConfig?.font_size;
|
const savedSize = $appConfig?.font_size;
|
||||||
if (savedSize) {
|
if (savedSize) {
|
||||||
activeFontSize = savedSize;
|
activeFontSize = savedSize;
|
||||||
@@ -1139,7 +1168,7 @@
|
|||||||
|
|
||||||
{#if $showSettings}
|
{#if $showSettings}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="settings-overlay" class:mobile={isMobile} onclick={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-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2 id="settings-title">Settings</h2>
|
<h2 id="settings-title">Settings</h2>
|
||||||
@@ -1804,7 +1833,7 @@
|
|||||||
<!-- Custom Theme Editor Modal -->
|
<!-- Custom Theme Editor Modal -->
|
||||||
{#if customThemeEditorOpen && customThemeEditing}
|
{#if customThemeEditorOpen && customThemeEditing}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="custom-theme-modal-overlay" onclick={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" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
|
||||||
<div class="custom-theme-modal-header">
|
<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>
|
<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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !isMobile}
|
||||||
|
<label class="setting-toggle">
|
||||||
|
<span class="setting-label">
|
||||||
|
<span class="setting-name">Scroll to change font size</span>
|
||||||
|
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
|
||||||
|
</span>
|
||||||
|
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
|
||||||
|
<span class="toggle-knob"></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
@@ -2101,7 +2141,7 @@
|
|||||||
|
|
||||||
{#if restoreConfirm}
|
{#if restoreConfirm}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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">
|
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
|
||||||
<h4 id="restore-confirm-title">Restore Backup?</h4>
|
<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>
|
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
showSettings,
|
showSettings,
|
||||||
showInfo,
|
showInfo,
|
||||||
notebookIcons,
|
notebookIcons,
|
||||||
|
tagStyles,
|
||||||
appConfig,
|
appConfig,
|
||||||
quickAccessPaths,
|
quickAccessPaths,
|
||||||
collapsedNotebooks,
|
collapsedNotebooks,
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
notebookSortMode,
|
notebookSortMode,
|
||||||
notebookOrder
|
notebookOrder
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, 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 { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { readFile } from '@tauri-apps/plugin-fs';
|
import { readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
@@ -29,6 +30,8 @@
|
|||||||
import { isMobile } from '$lib/platform';
|
import { isMobile } from '$lib/platform';
|
||||||
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
|
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||||
import NotebookGlyph from './NotebookGlyph.svelte';
|
import NotebookGlyph from './NotebookGlyph.svelte';
|
||||||
|
import TagLabel from './TagLabel.svelte';
|
||||||
|
import TagStylePicker from './TagStylePicker.svelte';
|
||||||
import {
|
import {
|
||||||
NOTEBOOK_ICON_OPTIONS,
|
NOTEBOOK_ICON_OPTIONS,
|
||||||
decodeBuiltinNotebookIcon,
|
decodeBuiltinNotebookIcon,
|
||||||
@@ -91,6 +94,8 @@
|
|||||||
let iconPickerElement = $state<HTMLDivElement | null>(null);
|
let iconPickerElement = $state<HTMLDivElement | null>(null);
|
||||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tagsCollapsed = $state(true);
|
let tagsCollapsed = $state(true);
|
||||||
|
let tagContextMenu = $state<{ x: number; y: number; tag: string } | null>(null);
|
||||||
|
let stylePickerTag = $state<string | null>(null);
|
||||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||||
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -122,14 +127,16 @@
|
|||||||
try {
|
try {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// On mobile, parallelize and skip getAllTags (derive from $notes instead)
|
// 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(),
|
getNotebooks(),
|
||||||
getNotebookIcons(),
|
getNotebookIcons(),
|
||||||
|
getTagStyles(),
|
||||||
getQuickAccess(),
|
getQuickAccess(),
|
||||||
countRootNotes(),
|
countRootNotes(),
|
||||||
]);
|
]);
|
||||||
$notebooks = nbs;
|
$notebooks = nbs;
|
||||||
$notebookIcons = icons;
|
$notebookIcons = icons;
|
||||||
|
$tagStyles = styles;
|
||||||
$quickAccessPaths = qaNotes.map(n => n.relative_path);
|
$quickAccessPaths = qaNotes.map(n => n.relative_path);
|
||||||
$rootNoteCount = rootCount;
|
$rootNoteCount = rootCount;
|
||||||
} else {
|
} else {
|
||||||
@@ -138,6 +145,7 @@
|
|||||||
$rootNoteCount = rootCount;
|
$rootNoteCount = rootCount;
|
||||||
$tags = await getAllTags();
|
$tags = await getAllTags();
|
||||||
$notebookIcons = await getNotebookIcons();
|
$notebookIcons = await getNotebookIcons();
|
||||||
|
$tagStyles = await getTagStyles();
|
||||||
const qaNotes = await getQuickAccess();
|
const qaNotes = await getQuickAccess();
|
||||||
$quickAccessPaths = qaNotes.map(n => n.relative_path);
|
$quickAccessPaths = qaNotes.map(n => n.relative_path);
|
||||||
}
|
}
|
||||||
@@ -670,6 +678,26 @@
|
|||||||
contextMenu = null;
|
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) {
|
function onTrashContextMenu(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -695,12 +723,13 @@
|
|||||||
function handleWindowClick() {
|
function handleWindowClick() {
|
||||||
if (contextMenu) contextMenu = null;
|
if (contextMenu) contextMenu = null;
|
||||||
if (trashContextMenu) trashContextMenu = null;
|
if (trashContextMenu) trashContextMenu = null;
|
||||||
|
if (tagContextMenu) tagContextMenu = null;
|
||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if !isMobile}
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||||
@@ -721,7 +750,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !$sidebarCollapsed}
|
{#if isMobile || !$sidebarCollapsed}
|
||||||
{#if anyNavItem}
|
{#if anyNavItem}
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
{#if $appConfig?.show_all_notes !== false}
|
{#if $appConfig?.show_all_notes !== false}
|
||||||
@@ -885,16 +914,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if !tagsCollapsed}
|
{#if !tagsCollapsed}
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
{#each $tags as [tag, count]}
|
{#each $tags as [tag, count] (tag)}
|
||||||
<button
|
{@render tagRow(tag, count)}
|
||||||
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -910,16 +931,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if !tagsCollapsed}
|
{#if !tagsCollapsed}
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
{#each $tags as [tag, count]}
|
{#each $tags as [tag, count] (tag)}
|
||||||
<button
|
{@render tagRow(tag, count)}
|
||||||
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1016,6 +1029,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 trashContextMenu}
|
||||||
{#if isMobile}
|
{#if isMobile}
|
||||||
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
|
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
|
||||||
@@ -1045,6 +1075,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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)}
|
{#snippet notebookItem(nb: NotebookEntry, depth: number)}
|
||||||
{@const hasChildren = nb.children.length > 0}
|
{@const hasChildren = nb.children.length > 0}
|
||||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||||
@@ -1567,6 +1623,10 @@
|
|||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-item {
|
.tag-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1582,6 +1642,12 @@
|
|||||||
transition: all 0.1s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-label-slot {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-item:hover {
|
.tag-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
@@ -1591,16 +1657,6 @@
|
|||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-hash {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-name {
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-count {
|
.tag-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -2019,7 +2075,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .tag-item {
|
.sidebar.mobile .tag-item {
|
||||||
padding: 10px 16px;
|
padding: 10px 52px 10px 16px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { tags } from '$lib/stores/app';
|
import { tags } from '$lib/stores/app';
|
||||||
|
import TagLabel from './TagLabel.svelte';
|
||||||
|
|
||||||
let { existing = [], placeholder = 'Add tag...', onsubmit, oncancel }: {
|
let { existing = [], placeholder = 'Add tag...', onsubmit, oncancel }: {
|
||||||
existing?: string[];
|
existing?: string[];
|
||||||
@@ -69,14 +70,14 @@
|
|||||||
/>
|
/>
|
||||||
{#if suggestions.length}
|
{#if suggestions.length}
|
||||||
<div class="tag-suggest-list">
|
<div class="tag-suggest-list">
|
||||||
{#each suggestions as s, i}
|
{#each suggestions as s, i (s)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tag-suggest-item"
|
class="tag-suggest-item"
|
||||||
class:selected={i === selIndex}
|
class:selected={i === selIndex}
|
||||||
onmouseenter={() => (selIndex = i)}
|
onmouseenter={() => (selIndex = i)}
|
||||||
onmousedown={(e) => { e.preventDefault(); submit(s); }}
|
onmousedown={(e) => { e.preventDefault(); submit(s); }}
|
||||||
>#{s}</button>
|
><TagLabel name={s} size={12} /></button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -110,6 +111,8 @@
|
|||||||
gap: 1px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
.tag-suggest-item {
|
.tag-suggest-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
VaultState,
|
VaultState,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
SortMode,
|
SortMode,
|
||||||
|
TagStyle,
|
||||||
} from "$lib/types";
|
} from "$lib/types";
|
||||||
|
|
||||||
// App state
|
// App state
|
||||||
@@ -47,6 +48,7 @@ export const showSettings = writable(false);
|
|||||||
export const settingsTab = writable<string | null>(null);
|
export const settingsTab = writable<string | null>(null);
|
||||||
export const showInfo = writable(false);
|
export const showInfo = writable(false);
|
||||||
export const notebookIcons = writable<Record<string, string>>({});
|
export const notebookIcons = writable<Record<string, string>>({});
|
||||||
|
export const tagStyles = writable<Record<string, TagStyle>>({});
|
||||||
export const quickAccessPaths = writable<string[]>([]);
|
export const quickAccessPaths = writable<string[]>([]);
|
||||||
export const collapsedNotebooks = writable<string[]>([]);
|
export const collapsedNotebooks = writable<string[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export interface NotebookEntry {
|
|||||||
note_count: number;
|
note_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagStyle {
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NoteContent {
|
export interface NoteContent {
|
||||||
path: string;
|
path: string;
|
||||||
meta: NoteMeta;
|
meta: NoteMeta;
|
||||||
@@ -92,6 +97,7 @@ export interface AppConfig {
|
|||||||
system_dark_theme: string;
|
system_dark_theme: string;
|
||||||
accent_color: string | null;
|
accent_color: string | null;
|
||||||
font_size: number | null;
|
font_size: number | null;
|
||||||
|
scroll_to_change_font_size: boolean;
|
||||||
font_family: string | null;
|
font_family: string | null;
|
||||||
line_height: number | null;
|
line_height: number | null;
|
||||||
ui_scale: number | null;
|
ui_scale: number | null;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -3,6 +3,7 @@
|
|||||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||||
import { darkThemes, isIOS, isMobile } from '$lib/platform';
|
import { darkThemes, isIOS, isMobile } from '$lib/platform';
|
||||||
|
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
|
||||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||||
let removeFontSizeListener: (() => void) | null = null;
|
let removeFontSizeListener: (() => void) | null = null;
|
||||||
|
let removeScrollFontSizeListener: (() => void) | null = null;
|
||||||
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let startupWindowRevealed = false;
|
let startupWindowRevealed = false;
|
||||||
|
|
||||||
@@ -121,10 +123,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
if (!event.ctrlKey && !event.metaKey) return;
|
const action = getWheelFontSizeAction(
|
||||||
|
event,
|
||||||
|
$appConfig?.scroll_to_change_font_size ?? true
|
||||||
|
);
|
||||||
|
if (action === 'ignore') return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
zoomEditor(event.deltaY < 0 ? 1 : -1);
|
if (action === 'increase') zoomEditor(1);
|
||||||
|
else if (action === 'decrease') zoomEditor(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
@@ -153,6 +160,9 @@
|
|||||||
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
||||||
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
||||||
});
|
});
|
||||||
|
removeScrollFontSizeListener = await listen<boolean>('scroll-to-change-font-size-changed', (event) => {
|
||||||
|
if ($appConfig) $appConfig.scroll_to_change_font_size = event.payload;
|
||||||
|
});
|
||||||
$theme = config.theme || 'system';
|
$theme = config.theme || 'system';
|
||||||
|
|
||||||
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
||||||
@@ -287,6 +297,7 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
removeEditorZoomShortcuts?.();
|
removeEditorZoomShortcuts?.();
|
||||||
removeFontSizeListener?.();
|
removeFontSizeListener?.();
|
||||||
|
removeScrollFontSizeListener?.();
|
||||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||||
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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]))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user