mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-22 02:47:29 +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())
|
||||
}
|
||||
|
||||
// ── Tag Styles ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tag_styles(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<std::collections::HashMap<String, crate::types::TagStyle>, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
operations::load_tag_styles(vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_tag_style(
|
||||
state: State<'_, AppState>,
|
||||
tag: String,
|
||||
style: Option<crate::types::TagStyle>,
|
||||
) -> Result<(), String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
operations::set_tag_style(vault_path, &tag, style)
|
||||
}
|
||||
|
||||
// ── General Settings ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -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<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)
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -42,6 +42,28 @@ pub struct NotebookEntry {
|
||||
pub note_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TagStyle {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
impl TagStyle {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.icon
|
||||
.as_ref()
|
||||
.map(|value| value.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
&& self
|
||||
.color
|
||||
.as_ref()
|
||||
.map(|value| value.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NoteContent {
|
||||
pub path: String,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::types::{
|
||||
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
|
||||
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TagStyle, TrashContents,
|
||||
TrashNotebookEntry, VaultState,
|
||||
};
|
||||
use crate::vault::frontmatter;
|
||||
@@ -1448,6 +1448,100 @@ pub fn set_notebook_icon(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tag_styles_path(vault_path: &str) -> PathBuf {
|
||||
helixnotes_dir(vault_path).join("tag_styles.json")
|
||||
}
|
||||
|
||||
fn normalize_tag_style_key(tag: &str) -> String {
|
||||
tag.trim().to_string()
|
||||
}
|
||||
|
||||
fn tag_style_key_fold(tag: &str) -> String {
|
||||
tag.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn remove_matching_tag_styles(styles: &mut std::collections::HashMap<String, TagStyle>, tag: &str) {
|
||||
let fold = tag_style_key_fold(tag);
|
||||
styles.retain(|existing, _| tag_style_key_fold(existing) != fold);
|
||||
}
|
||||
|
||||
fn normalize_tag_color(color: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(color) = color.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let hex = color
|
||||
.strip_prefix('#')
|
||||
.ok_or_else(|| "Tag color must be a #hex value".to_string())?;
|
||||
if !matches!(hex.len(), 3 | 6) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("Tag color must be #RGB or #RRGGBB".to_string());
|
||||
}
|
||||
Ok(Some(format!("#{}", hex.to_ascii_lowercase())))
|
||||
}
|
||||
|
||||
fn normalize_tag_icon(icon: Option<&str>) -> Option<String> {
|
||||
icon.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.replace('\\', "/"))
|
||||
}
|
||||
|
||||
pub fn load_tag_styles(
|
||||
vault_path: &str,
|
||||
) -> Result<std::collections::HashMap<String, TagStyle>, String> {
|
||||
let styles_path = tag_styles_path(vault_path);
|
||||
if !styles_path.exists() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let data = fs::read_to_string(&styles_path).map_err(|e| e.to_string())?;
|
||||
let styles: std::collections::HashMap<String, TagStyle> =
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())?;
|
||||
let mut collapsed = std::collections::HashMap::new();
|
||||
for (tag, style) in styles {
|
||||
let key = normalize_tag_style_key(&tag);
|
||||
if key.is_empty() || style.is_empty() {
|
||||
continue;
|
||||
}
|
||||
remove_matching_tag_styles(&mut collapsed, &key);
|
||||
collapsed.insert(key, style);
|
||||
}
|
||||
Ok(collapsed)
|
||||
}
|
||||
|
||||
pub fn set_tag_style(vault_path: &str, tag: &str, style: Option<TagStyle>) -> Result<(), String> {
|
||||
let key = normalize_tag_style_key(tag);
|
||||
if key.is_empty() {
|
||||
return Err("Tag name is required".to_string());
|
||||
}
|
||||
|
||||
let mut styles = load_tag_styles(vault_path)?;
|
||||
let stored_key = styles
|
||||
.keys()
|
||||
.find(|existing| tag_style_key_fold(existing) == tag_style_key_fold(&key))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| key.clone());
|
||||
match style {
|
||||
Some(style) => {
|
||||
let next = TagStyle {
|
||||
icon: normalize_tag_icon(style.icon.as_deref()),
|
||||
color: normalize_tag_color(style.color.as_deref())?,
|
||||
};
|
||||
remove_matching_tag_styles(&mut styles, &key);
|
||||
if !next.is_empty() {
|
||||
styles.insert(stored_key, next);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
remove_matching_tag_styles(&mut styles, &key);
|
||||
}
|
||||
}
|
||||
|
||||
let dir = helixnotes_dir(vault_path);
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let data = serde_json::to_string_pretty(&styles).map_err(|e| e.to_string())?;
|
||||
fs::write(tag_styles_path(vault_path), data).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_quick_access(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let qa_path = helixnotes_dir(vault_path).join("quick_access.json");
|
||||
if qa_path.exists() {
|
||||
@@ -1566,9 +1660,10 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::{
|
||||
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
|
||||
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
|
||||
scan_notebooks, set_notebook_icon,
|
||||
helixnotes_dir, load_notebook_icons, load_tag_styles, permanent_delete, read_note,
|
||||
restore_notebook, scan_notebooks, set_notebook_icon, set_tag_style,
|
||||
};
|
||||
use crate::types::TagStyle;
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1660,6 +1755,144 @@ mod tests {
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_and_removes_tag_styles() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-tag-style-test-{}", Uuid::new_v4()));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
|
||||
set_tag_style(
|
||||
&vault_path,
|
||||
" Work ",
|
||||
Some(TagStyle {
|
||||
icon: Some("builtin:briefcase".into()),
|
||||
color: Some("#E11D48".into()),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stored: std::collections::HashMap<String, TagStyle> = serde_json::from_str(
|
||||
&fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(stored.contains_key("Work"));
|
||||
assert!(!stored.contains_key(" Work "));
|
||||
|
||||
let styles = load_tag_styles(&vault_path).unwrap();
|
||||
let work = styles.get("Work").expect("work tag style");
|
||||
assert_eq!(work.icon.as_deref(), Some("builtin:briefcase"));
|
||||
assert_eq!(work.color.as_deref(), Some("#e11d48"));
|
||||
|
||||
set_tag_style(&vault_path, "Work", None).unwrap();
|
||||
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_tag_style_is_case_insensitive() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-tag-style-case-test-{}", Uuid::new_v4()));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
|
||||
set_tag_style(
|
||||
&vault_path,
|
||||
"Work",
|
||||
Some(TagStyle {
|
||||
icon: Some("builtin:briefcase".into()),
|
||||
color: Some("#e11d48".into()),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
set_tag_style(&vault_path, "work", None).unwrap();
|
||||
|
||||
let stored: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored, serde_json::json!({}));
|
||||
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_collapses_case_variant_tag_style_keys() {
|
||||
let vault = std::env::temp_dir().join(format!(
|
||||
"helixnotes-tag-style-collapse-test-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
|
||||
fs::write(
|
||||
helixnotes_dir(&vault_path).join("tag_styles.json"),
|
||||
r##"{"Work":{"icon":"builtin:briefcase"},"work":{"color":"#e11d48"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let styles = load_tag_styles(&vault_path).unwrap();
|
||||
assert_eq!(styles.len(), 1);
|
||||
let (key, style) = styles.iter().next().unwrap();
|
||||
assert_eq!(key.to_ascii_lowercase(), "work");
|
||||
assert!(style.icon.is_some() || style.color.is_some());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_tag_color() {
|
||||
let vault = std::env::temp_dir().join(format!(
|
||||
"helixnotes-tag-style-color-test-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
|
||||
let err = set_tag_style(
|
||||
&vault_path,
|
||||
"daily",
|
||||
Some(TagStyle {
|
||||
icon: None,
|
||||
color: Some("red".into()),
|
||||
}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("hex"));
|
||||
|
||||
fs::remove_dir_all(&vault).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_empty_tag_style_removes_entry() {
|
||||
let vault = std::env::temp_dir().join(format!(
|
||||
"helixnotes-tag-style-clear-test-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
|
||||
set_tag_style(
|
||||
&vault_path,
|
||||
"daily",
|
||||
Some(TagStyle {
|
||||
icon: Some("builtin:calendar".into()),
|
||||
color: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
set_tag_style(
|
||||
&vault_path,
|
||||
"daily",
|
||||
Some(TagStyle {
|
||||
icon: None,
|
||||
color: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_only_requested_note_switcher_titles() {
|
||||
let vault =
|
||||
|
||||
@@ -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<Record<string, TagStyle>> {
|
||||
return invoke("get_tag_styles");
|
||||
}
|
||||
|
||||
export async function setTagStyle(
|
||||
tag: string,
|
||||
style: TagStyle | null,
|
||||
): Promise<void> {
|
||||
return invoke("set_tag_style", { tag, style });
|
||||
}
|
||||
|
||||
export async function setGeneralSettings(
|
||||
compactNotes: boolean,
|
||||
timeFormat: string,
|
||||
|
||||
@@ -10,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}<TagLabel name={$activeTag} size={16} />{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
|
||||
{/if}
|
||||
</span>
|
||||
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
<span class="meta-divider">·</span>
|
||||
<button class="note-tags-trigger" onclick={toggleTagMenu} title="Edit tags">
|
||||
{#if $activeNote.meta.tags?.length > 0}
|
||||
{#each $activeNote.meta.tags as tag}<span class="note-tag">#{tag}</span>{/each}
|
||||
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}<TagLabel name={tag} size={11} tone="muted" />{/each}
|
||||
{:else}
|
||||
<span class="note-tags-add">+ Tags</span>
|
||||
{/if}
|
||||
@@ -6461,8 +6463,8 @@
|
||||
<div class="info-row info-row-tags">
|
||||
<span class="info-key">Tags</span>
|
||||
<span class="info-value info-tags">
|
||||
{#each $activeNote.meta.tags as tag}
|
||||
<span class="info-tag">#{tag}</span>
|
||||
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}
|
||||
<TagLabel name={tag} size={11} tone="muted" />
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
@@ -7459,9 +7461,9 @@
|
||||
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px">
|
||||
{#if $activeNote.meta.tags.length > 0}
|
||||
<div class="tag-menu-list">
|
||||
{#each $activeNote.meta.tags as tag}
|
||||
{#each $activeNote.meta.tags as tag, i (tagIterationKey(tag, i))}
|
||||
<span class="tag-menu-chip">
|
||||
#{tag}
|
||||
<TagLabel name={tag} size={12} />
|
||||
<button class="tag-menu-remove" onclick={() => removeActiveNoteTag(tag)} title="Remove tag" aria-label="Remove tag">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@
|
||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import { tagIterationKey } from '$lib/utils/tag-styles';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||
import TasksView from './TasksView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import TagLabel from './TagLabel.svelte';
|
||||
import { isMobile, isAndroid } from '$lib/platform';
|
||||
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
@@ -1016,7 +1018,13 @@
|
||||
|
||||
<div class="note-list" class:mobile={isMobile}>
|
||||
<div class="list-header">
|
||||
<span class="list-title">{viewTitle}</span>
|
||||
<span class="list-title">
|
||||
{#if $viewMode === 'tag' && $activeTag}
|
||||
<TagLabel name={$activeTag} size={16} />
|
||||
{:else}
|
||||
{viewTitle}
|
||||
{/if}
|
||||
</span>
|
||||
<div class="list-actions">
|
||||
{#if !isMobile}
|
||||
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
|
||||
@@ -1133,9 +1141,9 @@
|
||||
</div>
|
||||
{#if tagEditTags.length > 0}
|
||||
<div class="tag-edit-list">
|
||||
{#each tagEditTags as tag}
|
||||
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
|
||||
<div class="tag-edit-item">
|
||||
<span class="tag-edit-name">#{tag}</span>
|
||||
<span class="tag-edit-name"><TagLabel name={tag} size={12} /></span>
|
||||
<button class="tag-edit-remove" onclick={() => removeTagFromBatch(tag)} title="Remove from all">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -1377,8 +1385,8 @@
|
||||
{#if showDates}<span class="note-date" title={`Created ${formatDate(note.meta.created)}\nModified ${formatDate(note.meta.modified)}`}>{formatRelativeTime($sortMode === 'created' ? note.meta.created : note.meta.modified)}</span>{/if}
|
||||
{#if note.meta.tags.length > 0}
|
||||
<span class="note-tags">
|
||||
{#each note.meta.tags.slice(0, 3) as tag}
|
||||
<span class="mini-tag">#{tag}</span>
|
||||
{#each note.meta.tags.slice(0, 3) as tag, i (tagIterationKey(tag, i))}
|
||||
<TagLabel name={tag} size={10} tone="accent" />
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1445,9 +1453,9 @@
|
||||
</div>
|
||||
{#if tagEditTags.length > 0}
|
||||
<div class="tag-edit-list">
|
||||
{#each tagEditTags as tag}
|
||||
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
|
||||
<div class="tag-edit-item">
|
||||
<span class="tag-edit-name">#{tag}</span>
|
||||
<span class="tag-edit-name"><TagLabel name={tag} size={12} /></span>
|
||||
<button class="tag-edit-remove" onclick={() => removeTagFromBatch(tag)} title="Remove from all">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -1497,9 +1505,9 @@
|
||||
</div>
|
||||
{#if tagEditTags.length > 0}
|
||||
<div class="tag-edit-list">
|
||||
{#each tagEditTags as tag}
|
||||
{#each tagEditTags as tag, i (tagIterationKey(tag, i))}
|
||||
<div class="tag-edit-item">
|
||||
<span class="tag-edit-name">#{tag}</span>
|
||||
<span class="tag-edit-name"><TagLabel name={tag} size={12} /></span>
|
||||
<button class="tag-edit-remove" onclick={() => removeTagFromNote(tag)} title="Remove tag">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -1683,6 +1691,10 @@
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
@@ -2018,14 +2030,6 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mini-tag {
|
||||
font-size: 10px;
|
||||
color: var(--text-accent);
|
||||
background: var(--accent-light);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
@@ -2290,7 +2294,7 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note-list.mobile .mini-tag {
|
||||
.note-list.mobile :global(.tag-label.tone-accent) {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
showSettings,
|
||||
showInfo,
|
||||
notebookIcons,
|
||||
tagStyles,
|
||||
appConfig,
|
||||
quickAccessPaths,
|
||||
collapsedNotebooks,
|
||||
@@ -21,7 +22,7 @@
|
||||
notebookSortMode,
|
||||
notebookOrder
|
||||
} from '$lib/stores/app';
|
||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
|
||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, getTagStyles, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { readFile } from '@tauri-apps/plugin-fs';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
@@ -29,6 +30,8 @@
|
||||
import { isMobile } from '$lib/platform';
|
||||
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import NotebookGlyph from './NotebookGlyph.svelte';
|
||||
import TagLabel from './TagLabel.svelte';
|
||||
import TagStylePicker from './TagStylePicker.svelte';
|
||||
import {
|
||||
NOTEBOOK_ICON_OPTIONS,
|
||||
decodeBuiltinNotebookIcon,
|
||||
@@ -91,6 +94,8 @@
|
||||
let iconPickerElement = $state<HTMLDivElement | null>(null);
|
||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let tagsCollapsed = $state(true);
|
||||
let tagContextMenu = $state<{ x: number; y: number; tag: string } | null>(null);
|
||||
let stylePickerTag = $state<string | null>(null);
|
||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
||||
e.stopPropagation();
|
||||
@@ -122,14 +127,16 @@
|
||||
try {
|
||||
if (isMobile) {
|
||||
// On mobile, parallelize and skip getAllTags (derive from $notes instead)
|
||||
const [nbs, icons, qaNotes, rootCount] = await Promise.all([
|
||||
const [nbs, icons, styles, qaNotes, rootCount] = await Promise.all([
|
||||
getNotebooks(),
|
||||
getNotebookIcons(),
|
||||
getTagStyles(),
|
||||
getQuickAccess(),
|
||||
countRootNotes(),
|
||||
]);
|
||||
$notebooks = nbs;
|
||||
$notebookIcons = icons;
|
||||
$tagStyles = styles;
|
||||
$quickAccessPaths = qaNotes.map(n => n.relative_path);
|
||||
$rootNoteCount = rootCount;
|
||||
} else {
|
||||
@@ -138,6 +145,7 @@
|
||||
$rootNoteCount = rootCount;
|
||||
$tags = await getAllTags();
|
||||
$notebookIcons = await getNotebookIcons();
|
||||
$tagStyles = await getTagStyles();
|
||||
const qaNotes = await getQuickAccess();
|
||||
$quickAccessPaths = qaNotes.map(n => n.relative_path);
|
||||
}
|
||||
@@ -670,6 +678,26 @@
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function onTagContextMenu(e: MouseEvent, tag: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { x, y } = clampMenu(e.clientX, e.clientY, 200, 80);
|
||||
tagContextMenu = { x, y, tag };
|
||||
}
|
||||
|
||||
function openTagMenu(e: Event, tag: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 80);
|
||||
tagContextMenu = { x, y, tag };
|
||||
}
|
||||
|
||||
function openTagStylePicker(tag: string) {
|
||||
tagContextMenu = null;
|
||||
stylePickerTag = tag;
|
||||
}
|
||||
|
||||
function onTrashContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -695,10 +723,11 @@
|
||||
function handleWindowClick() {
|
||||
if (contextMenu) contextMenu = null;
|
||||
if (trashContextMenu) trashContextMenu = null;
|
||||
if (tagContextMenu) tagContextMenu = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') { iconPickerNotebook = null; tagContextMenu = null; } }} />
|
||||
|
||||
<aside class="sidebar" class:collapsed={!isMobile && $sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
@@ -885,16 +914,8 @@
|
||||
</button>
|
||||
{#if !tagsCollapsed}
|
||||
<div class="tag-list">
|
||||
{#each $tags as [tag, count]}
|
||||
<button
|
||||
class="tag-item"
|
||||
class:active={$viewMode === 'tag' && $activeTag === tag}
|
||||
onclick={() => selectTag(tag)}
|
||||
>
|
||||
<span class="tag-hash">#</span>
|
||||
<span class="tag-name">{tag}</span>
|
||||
<span class="tag-count">{count}</span>
|
||||
</button>
|
||||
{#each $tags as [tag, count] (tag)}
|
||||
{@render tagRow(tag, count)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -910,16 +931,8 @@
|
||||
</button>
|
||||
{#if !tagsCollapsed}
|
||||
<div class="tag-list">
|
||||
{#each $tags as [tag, count]}
|
||||
<button
|
||||
class="tag-item"
|
||||
class:active={$viewMode === 'tag' && $activeTag === tag}
|
||||
onclick={() => selectTag(tag)}
|
||||
>
|
||||
<span class="tag-hash">#</span>
|
||||
<span class="tag-name">{tag}</span>
|
||||
<span class="tag-count">{count}</span>
|
||||
</button>
|
||||
{#each $tags as [tag, count] (tag)}
|
||||
{@render tagRow(tag, count)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1016,6 +1029,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tagContextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close tag actions" onclick={() => tagContextMenu = null}></button>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {tagContextMenu.x}px; top: {tagContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<button onclick={() => openTagStylePicker(tagContextMenu!.tag)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
|
||||
Customize...
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stylePickerTag}
|
||||
<TagStylePicker tag={stylePickerTag} onclose={() => stylePickerTag = null} />
|
||||
{/if}
|
||||
|
||||
{#if trashContextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
|
||||
@@ -1045,6 +1075,32 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet tagRow(tag: string, count: number)}
|
||||
<div class="tag-row">
|
||||
<button
|
||||
class="tag-item"
|
||||
class:active={$viewMode === 'tag' && $activeTag === tag}
|
||||
onclick={() => selectTag(tag)}
|
||||
oncontextmenu={(e) => onTagContextMenu(e, tag)}
|
||||
>
|
||||
<span class="tag-label-slot">
|
||||
<TagLabel name={tag} size={isMobile ? 16 : 14} />
|
||||
</span>
|
||||
<span class="tag-count">{count}</span>
|
||||
</button>
|
||||
{#if isMobile}
|
||||
<button
|
||||
type="button"
|
||||
class="notebook-actions-btn"
|
||||
aria-label={`Customize ${tag}`}
|
||||
onclick={(e) => openTagMenu(e, tag)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet notebookItem(nb: NotebookEntry, depth: number)}
|
||||
{@const hasChildren = nb.children.length > 0}
|
||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||
@@ -1567,6 +1623,10 @@
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1582,6 +1642,12 @@
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.tag-label-slot {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tag-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
@@ -1591,16 +1657,6 @@
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.tag-hash {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
@@ -2019,7 +2075,7 @@
|
||||
}
|
||||
|
||||
.sidebar.mobile .tag-item {
|
||||
padding: 10px 16px;
|
||||
padding: 10px 52px 10px 16px;
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
import { onMount } from 'svelte';
|
||||
import { tags } from '$lib/stores/app';
|
||||
import TagLabel from './TagLabel.svelte';
|
||||
|
||||
let { existing = [], placeholder = 'Add tag...', onsubmit, oncancel }: {
|
||||
existing?: string[];
|
||||
@@ -69,14 +70,14 @@
|
||||
/>
|
||||
{#if suggestions.length}
|
||||
<div class="tag-suggest-list">
|
||||
{#each suggestions as s, i}
|
||||
{#each suggestions as s, i (s)}
|
||||
<button
|
||||
type="button"
|
||||
class="tag-suggest-item"
|
||||
class:selected={i === selIndex}
|
||||
onmouseenter={() => (selIndex = i)}
|
||||
onmousedown={(e) => { e.preventDefault(); submit(s); }}
|
||||
>#{s}</button>
|
||||
><TagLabel name={s} size={12} /></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -110,6 +111,8 @@
|
||||
gap: 1px;
|
||||
}
|
||||
.tag-suggest-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
VaultState,
|
||||
ViewMode,
|
||||
SortMode,
|
||||
TagStyle,
|
||||
} from "$lib/types";
|
||||
|
||||
// App state
|
||||
@@ -47,6 +48,7 @@ export const showSettings = writable(false);
|
||||
export const settingsTab = writable<string | null>(null);
|
||||
export const showInfo = writable(false);
|
||||
export const notebookIcons = writable<Record<string, string>>({});
|
||||
export const tagStyles = writable<Record<string, TagStyle>>({});
|
||||
export const quickAccessPaths = writable<string[]>([]);
|
||||
export const collapsedNotebooks = writable<string[]>([]);
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface NotebookEntry {
|
||||
note_count: number;
|
||||
}
|
||||
|
||||
export interface TagStyle {
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface NoteContent {
|
||||
path: string;
|
||||
meta: NoteMeta;
|
||||
|
||||
@@ -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