diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0fe9e04..73a62a8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1588,6 +1588,28 @@ pub fn set_notebook_icon( 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, 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, +) -> Result<(), String> { + let config = state.config.lock().map_err(|e| e.to_string())?; + let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; + operations::set_tag_style(vault_path, &tag, style) +} + // ── General Settings ── #[tauri::command] @@ -1803,7 +1825,7 @@ pub struct OrphanAttachment { // Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every // .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files) -// plus notebook_icons.json, and matches each filename against both the raw text and a +// plus notebook_icons.json and tag_styles.json, and matches each filename against both the raw text and a // percent-decoded copy (so a URL-encoded path like `my%20file.png` still counts as a reference). // When in doubt a file is KEPT: a leftover orphan is harmless, a wrong deletion is not. fn scan_orphaned_attachments(vault: &str) -> Result, String> { @@ -1841,11 +1863,13 @@ fn scan_orphaned_attachments(vault: &str) -> Result, String> } } } - // Folder icons live in attachments but are referenced here, not in notes. (#157) - let icons_path = operations::helixnotes_dir(vault).join("notebook_icons.json"); - if let Ok(content) = std::fs::read_to_string(&icons_path) { - haystack.push_str(&content); - haystack.push('\n'); + // Folder and tag icons live in attachments but are referenced here, not in notes. (#157) + for mapping in ["notebook_icons.json", "tag_styles.json"] { + let mapping_path = operations::helixnotes_dir(vault).join(mapping); + if let Ok(content) = std::fs::read_to_string(&mapping_path) { + haystack.push_str(&content); + haystack.push('\n'); + } } let decoded = percent_decode(&haystack); let orphans = files diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0680ac0..f0a7ca3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -216,6 +216,8 @@ pub fn run() { commands::save_attachment, commands::get_notebook_icons, commands::set_notebook_icon, + commands::get_tag_styles, + commands::set_tag_style, commands::set_general_settings, commands::get_quick_access, commands::add_quick_access, diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 2855f47..0dac192 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -6,9 +6,9 @@ // vs manifest) and resolve each file as upload/download/delete, with keep-both // conflict copies so nothing is ever lost. // -// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and -// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata, -// and the manifest itself remain local-only. +// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, +// `.helixnotes/notebook_icons.json`, and `.helixnotes/tag_styles.json`. Search indexes, +// trash, history, other metadata, and the manifest itself remain local-only. use crate::state::AppState; use crate::vault::operations::helixnotes_dir; @@ -123,11 +123,13 @@ struct LocalFile { } /// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under -/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local +/// `.helixnotes/attachments/`, plus notebook icon and tag style mappings. Applied to BOTH local /// and remote so pointing at a folder with unrelated files never imports them. fn is_synced_relpath(rel: &str) -> bool { if rel.starts_with(".helixnotes/") { - rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json" + rel.starts_with(".helixnotes/attachments/") + || rel == ".helixnotes/notebook_icons.json" + || rel == ".helixnotes/tag_styles.json" } else { rel.ends_with(".md") } @@ -758,6 +760,7 @@ mod tests { "Notes/plan.md", ".helixnotes/attachments/notebook-icon.png", ".helixnotes/notebook_icons.json", + ".helixnotes/tag_styles.json", ] { assert!(is_synced_relpath(path), "expected {path} to be synced"); } @@ -766,6 +769,7 @@ mod tests { "Notes/image.png", ".helixnotes/sync_state.json", ".helixnotes/notebook_icons.json.bak", + ".helixnotes/tag_styles.json.bak", ".helixnotes/attachments-old/icon.png", ] { assert!(!is_synced_relpath(path), "expected {path} to stay local"); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 190290e..497141f 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -42,6 +42,28 @@ pub struct NotebookEntry { pub note_count: usize, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct TagStyle { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +impl TagStyle { + pub fn is_empty(&self) -> bool { + self.icon + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + && self + .color + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NoteContent { pub path: String, diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index 998fa3d..e47a4e8 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -1,5 +1,5 @@ use crate::types::{ - NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents, + NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TagStyle, TrashContents, TrashNotebookEntry, VaultState, }; use crate::vault::frontmatter; @@ -1448,6 +1448,100 @@ pub fn set_notebook_icon( Ok(()) } +fn tag_styles_path(vault_path: &str) -> PathBuf { + helixnotes_dir(vault_path).join("tag_styles.json") +} + +fn normalize_tag_style_key(tag: &str) -> String { + tag.trim().to_string() +} + +fn tag_style_key_fold(tag: &str) -> String { + tag.to_ascii_lowercase() +} + +fn remove_matching_tag_styles(styles: &mut std::collections::HashMap, 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, 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 { + icon.map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.replace('\\', "/")) +} + +pub fn load_tag_styles( + vault_path: &str, +) -> Result, 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 = + 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) -> 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, String> { let qa_path = helixnotes_dir(vault_path).join("quick_access.json"); if qa_path.exists() { @@ -1566,9 +1660,10 @@ pub fn sanitize_filename(name: &str) -> String { mod tests { use super::{ compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles, - helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook, - scan_notebooks, set_notebook_icon, + helixnotes_dir, load_notebook_icons, load_tag_styles, permanent_delete, read_note, + restore_notebook, scan_notebooks, set_notebook_icon, set_tag_style, }; + use crate::types::TagStyle; use std::fs; use uuid::Uuid; @@ -1660,6 +1755,144 @@ mod tests { fs::remove_dir_all(vault).unwrap(); } + #[test] + fn persists_and_removes_tag_styles() { + let vault = + std::env::temp_dir().join(format!("helixnotes-tag-style-test-{}", Uuid::new_v4())); + let vault_path = vault.to_string_lossy(); + + set_tag_style( + &vault_path, + " Work ", + Some(TagStyle { + icon: Some("builtin:briefcase".into()), + color: Some("#E11D48".into()), + }), + ) + .unwrap(); + + let stored: std::collections::HashMap = serde_json::from_str( + &fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(), + ) + .unwrap(); + assert!(stored.contains_key("Work")); + assert!(!stored.contains_key(" Work ")); + + let styles = load_tag_styles(&vault_path).unwrap(); + let work = styles.get("Work").expect("work tag style"); + assert_eq!(work.icon.as_deref(), Some("builtin:briefcase")); + assert_eq!(work.color.as_deref(), Some("#e11d48")); + + set_tag_style(&vault_path, "Work", None).unwrap(); + assert!(load_tag_styles(&vault_path).unwrap().is_empty()); + + fs::remove_dir_all(vault).unwrap(); + } + + #[test] + fn resetting_tag_style_is_case_insensitive() { + let vault = + std::env::temp_dir().join(format!("helixnotes-tag-style-case-test-{}", Uuid::new_v4())); + let vault_path = vault.to_string_lossy(); + + set_tag_style( + &vault_path, + "Work", + Some(TagStyle { + icon: Some("builtin:briefcase".into()), + color: Some("#e11d48".into()), + }), + ) + .unwrap(); + + set_tag_style(&vault_path, "work", None).unwrap(); + + let stored: serde_json::Value = serde_json::from_str( + &fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(), + ) + .unwrap(); + assert_eq!(stored, serde_json::json!({})); + assert!(load_tag_styles(&vault_path).unwrap().is_empty()); + + fs::remove_dir_all(vault).unwrap(); + } + + #[test] + fn load_collapses_case_variant_tag_style_keys() { + let vault = std::env::temp_dir().join(format!( + "helixnotes-tag-style-collapse-test-{}", + Uuid::new_v4() + )); + let vault_path = vault.to_string_lossy(); + fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap(); + fs::write( + helixnotes_dir(&vault_path).join("tag_styles.json"), + r##"{"Work":{"icon":"builtin:briefcase"},"work":{"color":"#e11d48"}}"##, + ) + .unwrap(); + + let styles = load_tag_styles(&vault_path).unwrap(); + assert_eq!(styles.len(), 1); + let (key, style) = styles.iter().next().unwrap(); + assert_eq!(key.to_ascii_lowercase(), "work"); + assert!(style.icon.is_some() || style.color.is_some()); + + fs::remove_dir_all(vault).unwrap(); + } + + #[test] + fn rejects_invalid_tag_color() { + let vault = std::env::temp_dir().join(format!( + "helixnotes-tag-style-color-test-{}", + Uuid::new_v4() + )); + let vault_path = vault.to_string_lossy(); + + let err = set_tag_style( + &vault_path, + "daily", + Some(TagStyle { + icon: None, + color: Some("red".into()), + }), + ) + .unwrap_err(); + assert!(err.contains("hex")); + + fs::remove_dir_all(&vault).ok(); + } + + #[test] + fn clearing_empty_tag_style_removes_entry() { + let vault = std::env::temp_dir().join(format!( + "helixnotes-tag-style-clear-test-{}", + Uuid::new_v4() + )); + let vault_path = vault.to_string_lossy(); + + set_tag_style( + &vault_path, + "daily", + Some(TagStyle { + icon: Some("builtin:calendar".into()), + color: None, + }), + ) + .unwrap(); + set_tag_style( + &vault_path, + "daily", + Some(TagStyle { + icon: None, + color: None, + }), + ) + .unwrap(); + assert!(load_tag_styles(&vault_path).unwrap().is_empty()); + + fs::remove_dir_all(vault).unwrap(); + } + #[test] fn loads_only_requested_note_switcher_titles() { let vault = diff --git a/src/lib/api.ts b/src/lib/api.ts index 4a3b6ac..31c7831 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,6 +11,7 @@ import type { TrashContents, VaultState, VaultStats, + TagStyle, ImportResult, BackupEntry, VersionEntry, @@ -269,6 +270,17 @@ export async function setNotebookIcon( return invoke("set_notebook_icon", { notebookRelative, iconRelative }); } +export async function getTagStyles(): Promise> { + return invoke("get_tag_styles"); +} + +export async function setTagStyle( + tag: string, + style: TagStyle | null, +): Promise { + return invoke("set_tag_style", { tag, style }); +} + export async function setGeneralSettings( compactNotes: boolean, timeFormat: string, diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index 94b529e..e750231 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -10,6 +10,7 @@ import InfoPanel from './InfoPanel.svelte'; import TitleBar from './TitleBar.svelte'; import ResizeHandle from './ResizeHandle.svelte'; + import TagLabel from './TagLabel.svelte'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { sidebarWidth, @@ -855,7 +856,7 @@ {#if $mobileView === 'sidebar'} HelixNotes {:else} - {#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if} + {#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag' && $activeTag}{: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 $globalUpdateAvailable && $mobileView === 'sidebar'} @@ -1245,6 +1246,9 @@ .mobile-header-title { flex: 1; + display: flex; + align-items: center; + min-width: 0; font-size: 17px; font-weight: 600; color: var(--text-primary); diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 33fd809..c1feb5c 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -55,10 +55,12 @@ import { clearFormatting } from '$lib/editor/clearFormatting'; import { serializeInlineMarkdown } from '$lib/editor/markdown'; import { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility'; + import { tagIterationKey } from '$lib/utils/tag-styles'; import { replaceWithWikiLink } from '$lib/editor/wikiLinks'; import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths'; import GraphView from './GraphView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; + import TagLabel from './TagLabel.svelte'; import ImageViewer from './ImageViewer.svelte'; import { isMobile, isAndroid } from '$lib/platform'; import ResizeHandle from './ResizeHandle.svelte'; @@ -6136,7 +6138,7 @@ · @@ -8018,15 +8020,6 @@ cursor: pointer; } - .note-tag { - font-size: 11px; - color: var(--text-tertiary); - background: var(--bg-tertiary); - padding: 1px 7px; - border-radius: 10px; - letter-spacing: 0.01em; - } - .note-tags-add { font-size: 11px; color: var(--text-tertiary); @@ -11693,14 +11686,6 @@ justify-content: flex-end; } - .info-tag { - font-size: 11px; - color: var(--text-secondary); - background: var(--bg-tertiary); - border-radius: 3px; - padding: 1px 5px; - } - .editor-container.mobile .editor-body-row:has(.info-panel) > .editor-body { display: none; } diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index fef703a..1edf6e8 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -43,9 +43,11 @@ import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time'; import { openNoteWindow } from '$lib/utils/window'; import { encodeNoteDragPaths } from '$lib/utils/note-drag'; + import { tagIterationKey } from '$lib/utils/tag-styles'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; import TasksView from './TasksView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; + import TagLabel from './TagLabel.svelte'; import { isMobile, isAndroid } from '$lib/platform'; let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: { @@ -1016,7 +1018,13 @@
- {viewTitle} + + {#if $viewMode === 'tag' && $activeTag} + + {:else} + {viewTitle} + {/if} +
{#if !isMobile}
{#if tagEditTags.length > 0}
- {#each tagEditTags as tag} + {#each tagEditTags as tag, i (tagIterationKey(tag, i))}
- #{tag} + @@ -1377,8 +1385,8 @@ {#if showDates}{formatRelativeTime($sortMode === 'created' ? note.meta.created : note.meta.modified)}{/if} {#if note.meta.tags.length > 0} - {#each note.meta.tags.slice(0, 3) as tag} - #{tag} + {#each note.meta.tags.slice(0, 3) as tag, i (tagIterationKey(tag, i))} + {/each} {/if} @@ -1445,9 +1453,9 @@
{#if tagEditTags.length > 0}
- {#each tagEditTags as tag} + {#each tagEditTags as tag, i (tagIterationKey(tag, i))}
- #{tag} + @@ -1497,9 +1505,9 @@
{#if tagEditTags.length > 0}
- {#each tagEditTags as tag} + {#each tagEditTags as tag, i (tagIterationKey(tag, i))}
- #{tag} + @@ -1683,6 +1691,10 @@ } .list-title { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; font-weight: 600; font-size: 14px; color: var(--text-primary); @@ -2018,14 +2030,6 @@ gap: 4px; } - .mini-tag { - font-size: 10px; - color: var(--text-accent); - background: var(--accent-light); - padding: 1px 5px; - border-radius: 3px; - } - .rename-input { width: 100%; padding: 4px 8px; @@ -2290,7 +2294,7 @@ font-size: 12px; } - .note-list.mobile .mini-tag { + .note-list.mobile :global(.tag-label.tone-accent) { font-size: 11px; padding: 2px 6px; } diff --git a/src/lib/components/NoteWindow.svelte b/src/lib/components/NoteWindow.svelte index 21ef093..fb081a4 100644 --- a/src/lib/components/NoteWindow.svelte +++ b/src/lib/components/NoteWindow.svelte @@ -10,9 +10,10 @@ activeNotePath, editorDirty, readOnly, - sourceMode + sourceMode, + tagStyles } from '$lib/stores/app'; - import { readNote } from '$lib/api'; + import { getTagStyles, readNote } from '$lib/api'; import { keybindings, matchAction } from '$lib/keybindings'; import type { FileEvent } from '$lib/types'; @@ -85,6 +86,12 @@ }); await applyUiScale($appConfig?.ui_scale ?? 1); + try { + $tagStyles = await getTagStyles(); + } catch (e) { + console.error('Failed to load tag styles:', e); + } + try { const content = await readNote(notePath); $activeNote = content; diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 87a5137..ff4e697 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -14,6 +14,7 @@ showSettings, showInfo, notebookIcons, + tagStyles, appConfig, quickAccessPaths, collapsedNotebooks, @@ -21,7 +22,7 @@ notebookSortMode, notebookOrder } from '$lib/stores/app'; - import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api'; + import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, getTagStyles, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { readFile } from '@tauri-apps/plugin-fs'; import { convertFileSrc } from '@tauri-apps/api/core'; @@ -29,6 +30,8 @@ import { isMobile } from '$lib/platform'; import { decodeNoteDragPaths } from '$lib/utils/note-drag'; import NotebookGlyph from './NotebookGlyph.svelte'; + import TagLabel from './TagLabel.svelte'; + import TagStylePicker from './TagStylePicker.svelte'; import { NOTEBOOK_ICON_OPTIONS, decodeBuiltinNotebookIcon, @@ -91,6 +94,8 @@ let iconPickerElement = $state(null); let trashContextMenu = $state<{ x: number; y: number } | null>(null); let tagsCollapsed = $state(true); + let tagContextMenu = $state<{ x: number; y: number; tag: string } | null>(null); + let stylePickerTag = $state(null); let deleteConfirm = $state(null); function toggleCollapse(nb: NotebookEntry, e: Event) { e.stopPropagation(); @@ -122,14 +127,16 @@ try { if (isMobile) { // On mobile, parallelize and skip getAllTags (derive from $notes instead) - const [nbs, icons, qaNotes, rootCount] = await Promise.all([ + const [nbs, icons, styles, qaNotes, rootCount] = await Promise.all([ getNotebooks(), getNotebookIcons(), + getTagStyles(), getQuickAccess(), countRootNotes(), ]); $notebooks = nbs; $notebookIcons = icons; + $tagStyles = styles; $quickAccessPaths = qaNotes.map(n => n.relative_path); $rootNoteCount = rootCount; } else { @@ -138,6 +145,7 @@ $rootNoteCount = rootCount; $tags = await getAllTags(); $notebookIcons = await getNotebookIcons(); + $tagStyles = await getTagStyles(); const qaNotes = await getQuickAccess(); $quickAccessPaths = qaNotes.map(n => n.relative_path); } @@ -670,6 +678,26 @@ contextMenu = null; } + function onTagContextMenu(e: MouseEvent, tag: string) { + e.preventDefault(); + e.stopPropagation(); + const { x, y } = clampMenu(e.clientX, e.clientY, 200, 80); + tagContextMenu = { x, y, tag }; + } + + function openTagMenu(e: Event, tag: string) { + e.preventDefault(); + e.stopPropagation(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 80); + tagContextMenu = { x, y, tag }; + } + + function openTagStylePicker(tag: string) { + tagContextMenu = null; + stylePickerTag = tag; + } + function onTrashContextMenu(e: MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -695,10 +723,11 @@ function handleWindowClick() { if (contextMenu) contextMenu = null; if (trashContextMenu) trashContextMenu = null; + if (tagContextMenu) tagContextMenu = null; } - { if (e.key === 'Escape') iconPickerNotebook = null; }} /> + { if (e.key === 'Escape') { iconPickerNotebook = null; tagContextMenu = null; } }} />
{/if} +{#if tagContextMenu} + {#if isMobile} + + {/if} + +
e.stopPropagation()}> + +
+{/if} + +{#if stylePickerTag} + stylePickerTag = null} /> +{/if} + {#if trashContextMenu} {#if isMobile} @@ -1045,6 +1075,32 @@
{/if} +{#snippet tagRow(tag: string, count: number)} +
+ + {#if isMobile} + + {/if} +
+{/snippet} + {#snippet notebookItem(nb: NotebookEntry, depth: number)} {@const hasChildren = nb.children.length > 0} {@const isCollapsed = $collapsedNotebooks.includes(nb.path)} @@ -1567,6 +1623,10 @@ padding: 0 4px; } + .tag-row { + position: relative; + } + .tag-item { display: flex; align-items: center; @@ -1582,6 +1642,12 @@ transition: all 0.1s; } + .tag-label-slot { + flex: 1; + min-width: 0; + display: flex; + } + .tag-item:hover { background: var(--bg-hover); } @@ -1591,16 +1657,6 @@ color: var(--text-accent); } - .tag-hash { - color: var(--text-tertiary); - font-weight: 600; - } - - .tag-name { - flex: 1; - text-align: left; - } - .tag-count { font-size: 11px; color: var(--text-tertiary); @@ -2019,7 +2075,7 @@ } .sidebar.mobile .tag-item { - padding: 10px 16px; + padding: 10px 52px 10px 16px; min-height: 44px; font-size: 14px; } diff --git a/src/lib/components/TagLabel.svelte b/src/lib/components/TagLabel.svelte new file mode 100644 index 0000000..ff6cbbc --- /dev/null +++ b/src/lib/components/TagLabel.svelte @@ -0,0 +1,141 @@ + + + + {#if builtinIcon} + + + + {:else if imageSrc} + + {:else} + # + {/if} + {#if showName}{name}{/if} + + + diff --git a/src/lib/components/TagStylePicker.svelte b/src/lib/components/TagStylePicker.svelte new file mode 100644 index 0000000..7234289 --- /dev/null +++ b/src/lib/components/TagStylePicker.svelte @@ -0,0 +1,431 @@ + + + { + if (e.key === 'Escape') { + e.preventDefault(); + onclose(); + } + }} +/> + +
+ + +
+ + diff --git a/src/lib/components/TagSuggestInput.svelte b/src/lib/components/TagSuggestInput.svelte index b69bda6..1da64e3 100644 --- a/src/lib/components/TagSuggestInput.svelte +++ b/src/lib/components/TagSuggestInput.svelte @@ -1,6 +1,7 @@