mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-22 10:57:28 +02:00
Merge branch 'main' into 'main'
feat: add custom tag icons and colors See merge request ArkHost/HelixNotes!9
This commit is contained in:
@@ -1588,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]
|
||||||
@@ -1803,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> {
|
||||||
@@ -1841,11 +1863,13 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Folder icons live in attachments but are referenced here, not in notes. (#157)
|
// 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);
|
||||||
haystack.push_str(&content);
|
if let Ok(content) = std::fs::read_to_string(&mapping_path) {
|
||||||
haystack.push('\n');
|
haystack.push_str(&content);
|
||||||
|
haystack.push('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let decoded = percent_decode(&haystack);
|
let decoded = percent_decode(&haystack);
|
||||||
let orphans = files
|
let orphans = files
|
||||||
|
|||||||
@@ -216,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,
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
TrashContents,
|
TrashContents,
|
||||||
VaultState,
|
VaultState,
|
||||||
VaultStats,
|
VaultStats,
|
||||||
|
TagStyle,
|
||||||
ImportResult,
|
ImportResult,
|
||||||
BackupEntry,
|
BackupEntry,
|
||||||
VersionEntry,
|
VersionEntry,
|
||||||
@@ -269,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,
|
||||||
@@ -855,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'}
|
||||||
@@ -1245,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);
|
||||||
|
|||||||
@@ -55,10 +55,12 @@
|
|||||||
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 { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility';
|
||||||
|
import { tagIterationKey } from '$lib/utils/tag-styles';
|
||||||
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
|
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';
|
||||||
@@ -6136,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}
|
||||||
@@ -6461,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>
|
||||||
@@ -7459,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>
|
||||||
@@ -8018,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);
|
||||||
@@ -11693,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,10 +723,11 @@
|
|||||||
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={!isMobile && $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}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user