Merge branch 'main' into 'main'

feat: add custom tag icons and colors

See merge request ArkHost/HelixNotes!9
This commit is contained in:
Yuri Karamian
2026-09-21 09:08:33 +00:00
18 changed files with 1289 additions and 93 deletions
+28 -4
View File
@@ -1588,6 +1588,28 @@ pub fn set_notebook_icon(
operations::set_notebook_icon(vault_path, &notebook_relative, icon_relative.as_deref()) operations::set_notebook_icon(vault_path, &notebook_relative, icon_relative.as_deref())
} }
// ── Tag Styles ──
#[tauri::command]
pub fn get_tag_styles(
state: State<'_, AppState>,
) -> Result<std::collections::HashMap<String, crate::types::TagStyle>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::load_tag_styles(vault_path)
}
#[tauri::command]
pub fn set_tag_style(
state: State<'_, AppState>,
tag: String,
style: Option<crate::types::TagStyle>,
) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::set_tag_style(vault_path, &tag, style)
}
// ── General Settings ── // ── 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,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()
+2
View File
@@ -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,
+9 -5
View File
@@ -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");
+22
View File
@@ -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,
+236 -3
View File
@@ -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 =
+12
View File
@@ -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,
+5 -1
View File
@@ -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);
+7 -22
View File
@@ -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;
} }
+22 -18
View File
@@ -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;
} }
+9 -2
View File
@@ -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;
+90 -34
View File
@@ -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;
} }
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core';
import { appConfig, tagStyles } from '$lib/stores/app';
import NotebookGlyph from './NotebookGlyph.svelte';
import { decodeBuiltinNotebookIcon } from '$lib/utils/notebook-icons';
import { isValidTagColor, lookupTagStyle } from '$lib/utils/tag-styles';
let {
name,
size = 14,
tone = 'none',
showName = true
}: {
name: string;
size?: number;
tone?: 'none' | 'accent' | 'muted';
showName?: boolean;
} = $props();
const style = $derived(lookupTagStyle(name, $tagStyles));
const color = $derived.by(() => {
const value = style?.color;
return isValidTagColor(value) ? value : null;
});
const builtinIcon = $derived(decodeBuiltinNotebookIcon(style?.icon));
const imageSrc = $derived.by(() => {
const icon = style?.icon;
const vault = $appConfig?.active_vault;
if (!icon || builtinIcon || icon.startsWith('builtin:') || !vault) return null;
return convertFileSrc(`${vault}/${icon}`);
});
</script>
<span
class={[
'tag-label',
`tone-${tone}`,
{ 'has-color': !!color, 'has-icon': !!(builtinIcon || imageSrc) }
]}
style:--tag-color={color}
title={`#${name}`}
>
{#if builtinIcon}
<span class="tag-icon">
<NotebookGlyph icon={builtinIcon} {size} />
</span>
{:else if imageSrc}
<img class="tag-image" src={imageSrc} alt="" width={size} height={size} />
{:else}
<span class="tag-hash">#</span>
{/if}
{#if showName}<span class="tag-name">{name}</span>{/if}
</span>
<style>
.tag-label {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
color: inherit;
line-height: 1.2;
}
.tag-label.has-color {
color: var(--tag-color);
}
.tag-icon,
.tag-image,
.tag-hash {
flex-shrink: 0;
}
.tag-icon {
display: grid;
place-items: center;
color: inherit;
}
.tag-image {
border-radius: 3px;
object-fit: cover;
}
.tag-hash {
font-weight: 600;
color: inherit;
opacity: 0.72;
}
.tag-label.has-color .tag-hash,
.tag-label.has-icon .tag-hash,
.tone-accent .tag-hash,
.tone-muted .tag-hash {
opacity: 1;
}
.tag-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tone-accent {
gap: 3px;
padding: 1px 5px;
border-radius: 3px;
background: var(--accent-light);
color: var(--text-accent);
font-size: 10px;
}
.tone-accent.has-color {
color: var(--tag-color);
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
}
.tone-muted {
gap: 3px;
padding: 1px 7px;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 11px;
letter-spacing: 0.01em;
}
.tone-muted.has-color {
color: var(--tag-color);
background: color-mix(in srgb, var(--tag-color) 16%, var(--bg-tertiary));
}
.tone-accent .tag-hash,
.tone-muted .tag-hash {
color: inherit;
font-weight: 600;
}
</style>
+431
View File
@@ -0,0 +1,431 @@
<script lang="ts">
import { onMount } from 'svelte';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { get } from 'svelte/store';
import { tagStyles } from '$lib/stores/app';
import { saveAttachment, setTagStyle } from '$lib/api';
import type { TagStyle } from '$lib/types';
import { isMobile } from '$lib/platform';
import NotebookGlyph from './NotebookGlyph.svelte';
import TagLabel from './TagLabel.svelte';
import {
NOTEBOOK_ICON_OPTIONS,
encodeBuiltinNotebookIcon,
type NotebookIconId
} from '$lib/utils/notebook-icons';
import {
TAG_COLOR_PRESETS,
applyCommittedTagStyle,
createTagStylePersister,
lookupTagStyle,
tagColor
} from '$lib/utils/tag-styles';
let {
tag,
onclose
}: {
tag: string;
onclose: () => void;
} = $props();
let dialogEl = $state<HTMLDivElement | null>(null);
const current = $derived(lookupTagStyle(tag, $tagStyles));
const activeIcon = $derived(current?.icon ?? null);
const activeColor = $derived(tagColor(tag, $tagStyles));
const colorInputValue = $derived(expandHex(activeColor) ?? '#5b6abf');
const hasStyle = $derived(!!(activeIcon || activeColor));
const persister = createTagStylePersister({
getCurrent: () => lookupTagStyle(tag, get(tagStyles)),
save: (next) => setTagStyle(tag, next)
});
onMount(() => dialogEl?.focus());
function expandHex(color: string | null): string | null {
if (!color) return null;
if (color.length === 4) {
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
}
return color;
}
function baseOf(path: string): string {
return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
}
async function persist(patch: TagStyle) {
const next = persister.apply(patch);
$tagStyles = applyCommittedTagStyle($tagStyles, tag, next);
try {
await persister.flush();
} catch (e) {
console.error('Failed to save tag style:', e);
}
}
function handleBuiltinIcon(icon: NotebookIconId) {
void persist({ icon: encodeBuiltinNotebookIcon(icon) });
}
async function handleCustomIcon() {
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
});
if (!selected) return;
const filePath = selected as string;
const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`tag-icon-${fileName}`, Array.from(data));
await persist({ icon: iconRelative });
} catch (e) {
console.error('Failed to set tag icon:', e);
}
}
async function handleReset() {
await persist({ icon: null, color: null });
onclose();
}
function onColorInput(e: Event) {
const value = (e.currentTarget as HTMLInputElement).value;
void persist({ color: value });
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onclose();
}
}}
/>
<div class="icon-picker-overlay" class:mobile={isMobile}>
<button class="icon-picker-backdrop" aria-label="Close tag appearance picker" onclick={onclose}></button>
<div
bind:this={dialogEl}
class="icon-picker"
role="dialog"
aria-modal="true"
aria-label={`Choose an icon and color for ${tag}`}
tabindex="-1"
>
<header class="icon-picker-header">
<div>
<h3>Tag appearance</h3>
<p>
<TagLabel name={tag} size={14} />
</p>
</div>
<button type="button" class="icon-picker-close" aria-label="Close tag appearance picker" onclick={onclose}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</header>
<div class="icon-picker-grid">
{#each NOTEBOOK_ICON_OPTIONS as option (option.id)}
<button
type="button"
class="icon-picker-option"
class:active={activeIcon === encodeBuiltinNotebookIcon(option.id)}
aria-label={option.label}
title={option.label}
onclick={() => handleBuiltinIcon(option.id)}
>
<NotebookGlyph icon={option.id} size={20} />
<span>{option.label}</span>
</button>
{/each}
</div>
<div class="color-section">
<span class="color-label">Color</span>
<div class="swatches">
<button
type="button"
class="swatch none"
class:active={!activeColor}
aria-label="Use default color"
title="Default color"
onclick={() => persist({ color: null })}
></button>
{#each TAG_COLOR_PRESETS as preset (preset)}
<button
type="button"
class="swatch"
class:active={activeColor?.toLowerCase() === preset}
style:background={preset}
aria-label={`Use ${preset}`}
title={preset}
onclick={() => persist({ color: preset })}
></button>
{/each}
<label class="swatch custom" title="Custom color">
<input type="color" value={colorInputValue} oninput={onColorInput} aria-label="Custom tag color" />
</label>
</div>
</div>
<div class="icon-picker-actions">
<button type="button" onclick={handleCustomIcon}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
Custom image...
</button>
{#if hasStyle}
<button type="button" class="remove" onclick={handleReset}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
Use default
</button>
{/if}
<p class="icon-picker-recommendation">Icons and colors are optional. Recommended image size: <strong>1:1</strong></p>
</div>
</div>
</div>
<style>
.icon-picker-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 20px;
}
.icon-picker-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: rgba(8, 10, 18, 0.42);
cursor: default;
}
.icon-picker {
position: relative;
z-index: 1;
width: min(360px, 100%);
max-height: calc(100dvh - 40px);
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-lg);
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border-light);
}
.icon-picker-header h3,
.icon-picker-header p {
margin: 0;
}
.icon-picker-header h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
}
.icon-picker-header p {
margin-top: 6px;
color: var(--text-tertiary);
font-size: 12px;
}
.icon-picker-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.icon-picker-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
padding: 14px;
}
.icon-picker-option {
min-width: 0;
min-height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 3px 6px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.icon-picker-option span {
max-width: 100%;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-option:hover {
border-color: var(--border-color);
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-option.active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
}
.icon-picker-option.active span {
color: var(--accent);
}
.color-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 14px 12px;
}
.color-label {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 7px;
align-items: center;
}
.swatch {
width: 22px;
height: 22px;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
background: var(--bg-tertiary);
cursor: pointer;
}
.swatch:hover,
.swatch.active {
border-color: var(--text-primary);
}
.swatch.none {
background:
linear-gradient(to bottom right, transparent calc(50% - 1px), var(--text-tertiary) calc(50% - 1px), var(--text-tertiary) calc(50% + 1px), transparent calc(50% + 1px)),
var(--bg-secondary);
border-color: var(--border-color);
}
.swatch.custom {
display: grid;
place-items: center;
overflow: hidden;
background: conic-gradient(from 90deg, #e11d48, #eab308, #22c55e, #3b82f6, #8b5cf6, #e11d48);
}
.swatch.custom input {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: none;
cursor: pointer;
transform: scale(1.4);
}
.icon-picker-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-light);
}
.icon-picker-recommendation {
flex: 0 0 100%;
margin: 2px 0 0;
color: var(--text-tertiary);
font-size: 11px;
line-height: 1.3;
text-align: center;
}
.icon-picker-actions button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.icon-picker-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-actions button.remove {
color: var(--text-tertiary);
}
.icon-picker-overlay.mobile {
padding: 12px;
align-items: end;
}
.icon-picker-overlay.mobile .icon-picker {
width: 100%;
max-height: min(86dvh, 640px);
}
.icon-picker-overlay.mobile .icon-picker-option {
min-height: 64px;
}
.icon-picker-overlay.mobile .icon-picker-actions button {
min-height: 44px;
}
</style>
+5 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <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;
+2
View File
@@ -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[]>([]);
+5
View File
@@ -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;
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const {
applyCommittedTagStyle,
createTagStylePersister,
isValidTagColor,
lookupTagStyle,
nextTagStyle,
normalizeTagStyleKey,
tagColor,
tagCustomImagePath,
tagIterationKey,
tagStyleKeyEquals
} = await import(new URL('./tag-styles.ts', import.meta.url));
test('normalizes tag style keys by trimming', () => {
assert.equal(normalizeTagStyleKey(' work '), 'work');
});
test('accepts 3 and 6 digit hex colors only', () => {
assert.equal(isValidTagColor('#e11'), true);
assert.equal(isValidTagColor('#e11d48'), true);
assert.equal(isValidTagColor('#E11D48'), true);
assert.equal(isValidTagColor('red'), false);
assert.equal(isValidTagColor('#gg0000'), false);
assert.equal(isValidTagColor('#e11d48aa'), false);
});
test('looks up tag styles with a case-insensitive fallback', () => {
const styles = { Work: { icon: 'builtin:briefcase', color: '#e11d48' } };
assert.equal(lookupTagStyle('Work', styles)?.icon, 'builtin:briefcase');
assert.equal(lookupTagStyle('work', styles)?.color, '#e11d48');
assert.equal(tagColor('missing', styles), null);
assert.equal(tagStyleKeyEquals('Work', 'work'), true);
});
test('treats non-builtin icons as custom image paths', () => {
const styles = { daily: { icon: '.helixnotes/attachments/tag.png' } };
assert.equal(tagCustomImagePath('daily', styles), '.helixnotes/attachments/tag.png');
assert.equal(tagCustomImagePath('daily', { daily: { icon: 'builtin:calendar' } }), null);
});
test('merges style patches and clears empty styles', () => {
const current = { icon: 'builtin:star', color: '#3b82f6' };
assert.deepEqual(nextTagStyle(current, { color: '#22c55e' }), {
icon: 'builtin:star',
color: '#22c55e'
});
assert.equal(nextTagStyle(current, { icon: null, color: null }), null);
});
test('resetting a differently cased tag removes the stored style', () => {
const styles = { Work: { icon: 'builtin:briefcase', color: '#e11d48' } };
assert.deepEqual(applyCommittedTagStyle(styles, 'work', null), {});
});
test('saving a style replaces a differently cased stored key', () => {
const styles = { Work: { icon: 'builtin:briefcase' } };
const next = applyCommittedTagStyle(styles, 'work', {
icon: 'builtin:star',
color: '#ec4899'
});
assert.deepEqual(next, {
Work: { icon: 'builtin:star', color: '#ec4899' }
});
assert.equal(Object.hasOwn(next, 'work'), false);
});
test('queued icon then color patches keep both fields', async () => {
const writes = [];
let releaseFirst;
const firstWrite = new Promise((resolve) => {
releaseFirst = resolve;
});
const persister = createTagStylePersister({
getCurrent: () => undefined,
async save(next) {
writes.push(next);
if (writes.length === 1) await firstWrite;
}
});
assert.deepEqual(persister.apply({ icon: 'builtin:star' }), { icon: 'builtin:star' });
assert.deepEqual(persister.apply({ color: '#ec4899' }), {
icon: 'builtin:star',
color: '#ec4899'
});
releaseFirst();
await persister.flush();
assert.deepEqual(writes, [
{ icon: 'builtin:star' },
{ icon: 'builtin:star', color: '#ec4899' }
]);
});
test('queued reset after a pending save clears the stored style', async () => {
let current = { icon: 'builtin:star' };
const writes = [];
let releaseFirst;
const firstWrite = new Promise((resolve) => {
releaseFirst = resolve;
});
const persister = createTagStylePersister({
getCurrent: () => current,
async save(next) {
writes.push(next);
current = next ?? undefined;
if (writes.length === 1) await firstWrite;
}
});
persister.apply({ color: '#8b5cf6' });
assert.equal(persister.apply({ icon: null, color: null }), null);
releaseFirst();
await persister.flush();
assert.deepEqual(writes, [
{ icon: 'builtin:star', color: '#8b5cf6' },
null
]);
assert.deepEqual(applyCommittedTagStyle({ work: writes[0] }, 'Work', writes[1]), {});
});
test('duplicate tag names still get unique each keys', () => {
const tags = ['work', 'work'];
const keys = tags.map((tag, index) => tagIterationKey(tag, index));
assert.deepEqual(keys, ['0:work', '1:work']);
assert.equal(new Set(keys).size, keys.length);
});
+126
View File
@@ -0,0 +1,126 @@
export interface TagStyleFields {
icon?: string | null;
color?: string | null;
}
export const TAG_COLOR_PRESETS = [
'#e11d48',
'#f97316',
'#eab308',
'#22c55e',
'#14b8a6',
'#3b82f6',
'#8b5cf6',
'#ec4899',
'#64748b'
];
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const BUILTIN_PREFIX = 'builtin:';
export function normalizeTagStyleKey(tag: string): string {
return tag.trim();
}
export function isValidTagColor(value: string | null | undefined): value is string {
return typeof value === 'string' && HEX_COLOR.test(value);
}
export function tagStyleKeyEquals(a: string, b: string): boolean {
return normalizeTagStyleKey(a).toLowerCase() === normalizeTagStyleKey(b).toLowerCase();
}
export function existingTagStyleKey(
tag: string,
styles: Record<string, TagStyleFields>
): string | undefined {
const key = normalizeTagStyleKey(tag);
if (!key) return undefined;
if (Object.hasOwn(styles, key)) return key;
const lower = key.toLowerCase();
return Object.keys(styles).find((name) => name.toLowerCase() === lower);
}
export function lookupTagStyle(
tag: string,
styles: Record<string, TagStyleFields>
): TagStyleFields | undefined {
const storedKey = existingTagStyleKey(tag, styles);
return storedKey ? styles[storedKey] : undefined;
}
/** Stable `{#each}` key when a note can contain duplicate tag names. */
export function tagIterationKey(tag: string, index: number): string {
return `${index}:${tag}`;
}
export function tagColor(
tag: string,
styles: Record<string, TagStyleFields>
): string | null {
const color = lookupTagStyle(tag, styles)?.color;
return isValidTagColor(color) ? color : null;
}
export function tagCustomImagePath(
tag: string,
styles: Record<string, TagStyleFields>
): string | null {
const icon = lookupTagStyle(tag, styles)?.icon;
if (!icon || icon.startsWith(BUILTIN_PREFIX)) return null;
return icon;
}
export function nextTagStyle(
current: TagStyleFields | undefined,
patch: TagStyleFields
): TagStyleFields | null {
const icon = patch.icon === undefined ? current?.icon : patch.icon;
const color = patch.color === undefined ? current?.color : patch.color;
const next: TagStyleFields = {};
if (typeof icon === 'string' && icon.trim()) next.icon = icon;
if (isValidTagColor(color)) next.color = color;
return next.icon || next.color ? next : null;
}
export function applyCommittedTagStyle(
styles: Record<string, TagStyleFields>,
tag: string,
next: TagStyleFields | null
): Record<string, TagStyleFields> {
const result = { ...styles };
const key = normalizeTagStyleKey(tag);
if (!key) return result;
const lower = key.toLowerCase();
for (const name of Object.keys(result)) {
if (name.toLowerCase() === lower) delete result[name];
}
if (next) {
const storedKey = existingTagStyleKey(tag, styles) ?? key;
result[storedKey] = next;
}
return result;
}
export function createTagStylePersister(options: {
getCurrent: () => TagStyleFields | undefined;
save: (next: TagStyleFields | null) => Promise<void>;
}) {
let draft: TagStyleFields | null | undefined;
let queue = Promise.resolve();
return {
apply(patch: TagStyleFields): TagStyleFields | null {
const current = draft === undefined ? options.getCurrent() : (draft ?? undefined);
draft = nextTagStyle(current, patch);
const snapshot = draft;
const write = () => options.save(snapshot);
queue = queue.then(write, write);
return snapshot;
},
flush() {
return queue;
}
};
}