mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-21 18:37:30 +02:00
Compare commits
14
Commits
d7e4be7f3a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3607cf1b3f | ||
|
|
245cdacb68 | ||
|
|
2983e3db29 | ||
|
|
cc7c9e2b49 | ||
|
|
2240231f61 | ||
|
|
b2d0c7175e | ||
|
|
1bc0026731 | ||
|
|
41c0e4c993 | ||
|
|
34a0f9efa3 | ||
|
|
9d92489586 | ||
|
|
c45efb60ab | ||
|
|
0d11fd01dd | ||
|
|
0aaa05ce5c | ||
|
|
77e92ef25c |
@@ -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 =
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "HelixNotes",
|
||||
"label": "main",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
@@ -578,6 +579,11 @@
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
|
||||
if (!dismissesAppUi) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ($showSettings) $showSettings = false;
|
||||
else if ($showInfo) $showInfo = false;
|
||||
else if ($focusMode) $focusMode = false;
|
||||
@@ -850,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'}
|
||||
@@ -1240,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);
|
||||
|
||||
@@ -164,10 +164,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
$showCommandPalette = false;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
@@ -194,7 +199,7 @@
|
||||
|
||||
{#if $showCommandPalette}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydown={handleKeydown}>
|
||||
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="palette-input-wrapper">
|
||||
|
||||
@@ -54,10 +54,13 @@
|
||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||
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';
|
||||
@@ -117,9 +120,7 @@
|
||||
let hasPendingBlobs = false;
|
||||
let lastSourceMode = $sourceMode;
|
||||
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
|
||||
let titleWasStripped = false;
|
||||
let strippedTitle = '';
|
||||
let strippedHeadingPrefix = '';
|
||||
let hiddenTitleHeading: HiddenTitleHeading | null = null;
|
||||
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let taskRevealElement: HTMLElement | null = null;
|
||||
let taskRevealRequest = 0;
|
||||
@@ -3403,44 +3404,13 @@
|
||||
}
|
||||
|
||||
function stripTitleH1(md: string): string {
|
||||
const title = $activeNote?.meta.title;
|
||||
if (!$appConfig?.hide_title_in_body || !title) {
|
||||
titleWasStripped = false;
|
||||
strippedTitle = '';
|
||||
strippedHeadingPrefix = '';
|
||||
return md;
|
||||
}
|
||||
// Find the first non-empty line
|
||||
const lines = md.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') continue;
|
||||
// Check if it's a heading (any level) matching the note title
|
||||
// Normalize: lowercase, collapse whitespace, strip common separators (- - _)
|
||||
const normalize = (s: string) => s.trim().toLowerCase().replace(/[\s\-—_]+/g, ' ');
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match && normalize(match[2]) === normalize(title)) {
|
||||
titleWasStripped = true;
|
||||
strippedTitle = title.trim();
|
||||
strippedHeadingPrefix = match[1]; // preserve original heading level (e.g. "##")
|
||||
lines.splice(i, 1);
|
||||
// Also remove a trailing blank line after the heading if present
|
||||
if (i < lines.length && lines[i].trim() === '') {
|
||||
lines.splice(i, 1);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
break; // First non-empty line isn't a matching heading, stop
|
||||
}
|
||||
titleWasStripped = false;
|
||||
strippedTitle = '';
|
||||
strippedHeadingPrefix = '';
|
||||
return md;
|
||||
const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false);
|
||||
hiddenTitleHeading = result.hiddenTitle;
|
||||
return result.markdown;
|
||||
}
|
||||
|
||||
function restoreTitleH1(md: string): string {
|
||||
if (!titleWasStripped || !strippedTitle) return md;
|
||||
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
|
||||
return restoreTitleHeading(md, hiddenTitleHeading);
|
||||
}
|
||||
|
||||
function editorToMarkdown(): string {
|
||||
@@ -4996,13 +4966,25 @@
|
||||
}
|
||||
|
||||
async function ctxPaste() {
|
||||
if (!editor) return;
|
||||
// insertClipboardImage() saves the attachment before inserting it, so an
|
||||
// unguarded paste here would leave an orphan file behind when the editor
|
||||
// cannot accept the insertion.
|
||||
if (!editor || $readOnly || $viewerNote) { closeTextContextMenu(); return; }
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) editor.chain().focus().insertContent(text).run();
|
||||
if (text) {
|
||||
editor.chain().focus().insertContent(text).run();
|
||||
closeTextContextMenu();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Paste failed:', e);
|
||||
}
|
||||
// readText() only reads text/plain. An image copied from a browser has none,
|
||||
// so pasting an image from this menu used to do nothing at all. Fall back to
|
||||
// the same native clipboard reader the paste handler uses; it is a no-op when
|
||||
// the clipboard holds no image.
|
||||
await insertClipboardImage();
|
||||
closeTextContextMenu();
|
||||
}
|
||||
|
||||
@@ -5782,7 +5764,7 @@
|
||||
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
||||
: 0;
|
||||
const docNonWs = docNonWhitespace();
|
||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
||||
sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? ''));
|
||||
resetSourceHistory(sourceContent);
|
||||
lastSourceMode = true;
|
||||
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
||||
@@ -5809,20 +5791,20 @@
|
||||
};
|
||||
if (isMobile) {
|
||||
// Mobile: editor stays in DOM, just update its content
|
||||
const content = srcText || ($activeNote?.content ?? '');
|
||||
const content = srcText;
|
||||
if (editor) {
|
||||
ignoreNextUpdate = true;
|
||||
editor.commands.setContent(markdownToHtml(content));
|
||||
editor.commands.setContent(markdownToHtml(restoreTitleH1(content)));
|
||||
tick().then(restoreRichCaret);
|
||||
}
|
||||
} else {
|
||||
// Desktop: destroy old editor (its DOM element is gone),
|
||||
// wait for DOM to swap textarea→div, then create editor on new element.
|
||||
destroyEditor();
|
||||
const content = srcText || ($activeNote?.content ?? '');
|
||||
const content = srcText;
|
||||
tick().then(() => {
|
||||
if (editorElement && !editor) {
|
||||
createEditor(content);
|
||||
createEditor(restoreTitleH1(content));
|
||||
restoreRichCaret();
|
||||
}
|
||||
});
|
||||
@@ -5955,7 +5937,7 @@
|
||||
const oldPath = $activeNotePath;
|
||||
$activeNote.meta.title = newTitle;
|
||||
// Update stripped title so restoreTitleH1 uses the new title
|
||||
if (titleWasStripped) strippedTitle = newTitle;
|
||||
if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle };
|
||||
$editorDirty = true;
|
||||
// Force save current editor content before renaming so disk is up-to-date
|
||||
await forceSave();
|
||||
@@ -6156,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}
|
||||
@@ -6481,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>
|
||||
@@ -7479,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>
|
||||
@@ -8038,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);
|
||||
@@ -11713,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;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,13 @@
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
openUrl(url).catch(console.error);
|
||||
}
|
||||
@@ -104,7 +111,7 @@
|
||||
|
||||
{#if $showInfo}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<div class="info-overlay" onclick={closeFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
|
||||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h2>Info</h2>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,10 +44,15 @@
|
||||
if (item) item.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$showSearch = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
$showSearch = false;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
scrollToSelected();
|
||||
@@ -128,7 +133,7 @@
|
||||
|
||||
{#if $showSearch}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-overlay" onclick={close} onkeydown={handleKeydown}>
|
||||
<div class="search-overlay" onclick={close} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="search-input-wrapper">
|
||||
|
||||
@@ -521,6 +521,17 @@
|
||||
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
|
||||
];
|
||||
|
||||
// Keep the default themes pinned at the top and sort the rest alphabetically.
|
||||
const priorityThemeIds = ['system', 'light', 'dark'];
|
||||
themePresets.sort((a, b) => {
|
||||
const pa = priorityThemeIds.indexOf(a.id);
|
||||
const pb = priorityThemeIds.indexOf(b.id);
|
||||
if (pa !== -1 && pb !== -1) return pa - pb;
|
||||
if (pa !== -1) return -1;
|
||||
if (pb !== -1) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
const accentPresets = [
|
||||
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
|
||||
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
|
||||
@@ -1068,6 +1079,15 @@
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (restoreConfirm) restoreConfirm = null;
|
||||
else if (customThemeEditorOpen) cancelCustomThemeEditor();
|
||||
else close();
|
||||
}
|
||||
|
||||
function dismissRestoreConfirm(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) restoreConfirm = null;
|
||||
}
|
||||
@@ -1148,7 +1168,7 @@
|
||||
|
||||
{#if $showSettings}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
|
||||
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
||||
<div class="settings-header">
|
||||
<h2 id="settings-title">Settings</h2>
|
||||
@@ -1813,7 +1833,7 @@
|
||||
<!-- Custom Theme Editor Modal -->
|
||||
{#if customThemeEditorOpen && customThemeEditing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
|
||||
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={handleEscape}>
|
||||
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
|
||||
<div class="custom-theme-modal-header">
|
||||
<h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||
@@ -2121,7 +2141,7 @@
|
||||
|
||||
{#if restoreConfirm}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={(e) => { if (e.key === 'Escape') restoreConfirm = null; }}>
|
||||
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={handleEscape}>
|
||||
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
|
||||
<h4 id="restore-confirm-title">Restore Backup?</h4>
|
||||
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
||||
|
||||
@@ -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,12 +723,13 @@
|
||||
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={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
<aside class="sidebar" class:collapsed={!isMobile && $sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||
@@ -721,7 +750,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$sidebarCollapsed}
|
||||
{#if isMobile || !$sidebarCollapsed}
|
||||
{#if anyNavItem}
|
||||
<nav class="sidebar-nav">
|
||||
{#if $appConfig?.show_all_notes !== false}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export type HiddenTitleHeading = {
|
||||
headingPrefix: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type TitleHeadingResult = {
|
||||
markdown: string;
|
||||
hiddenTitle: HiddenTitleHeading | null;
|
||||
};
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[\s\-_\u2014]+/g, ' ');
|
||||
}
|
||||
|
||||
export function stripTitleHeading(
|
||||
markdown: string,
|
||||
title: string | undefined,
|
||||
hideTitle: boolean,
|
||||
): TitleHeadingResult {
|
||||
if (!hideTitle || !title) return { markdown, hiddenTitle: null };
|
||||
|
||||
const lines = markdown.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') continue;
|
||||
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match && normalizeTitle(match[2]) === normalizeTitle(title)) {
|
||||
const hiddenTitle = { headingPrefix: match[1], title: title.trim() };
|
||||
lines.splice(i, 1);
|
||||
if (i < lines.length && lines[i].trim() === '') lines.splice(i, 1);
|
||||
return { markdown: lines.join('\n'), hiddenTitle };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { markdown, hiddenTitle: null };
|
||||
}
|
||||
|
||||
export function restoreTitleHeading(
|
||||
markdown: string,
|
||||
hiddenTitle: HiddenTitleHeading | null,
|
||||
): string {
|
||||
if (!hiddenTitle) return markdown;
|
||||
return `${hiddenTitle.headingPrefix} ${hiddenTitle.title}\n\n${markdown}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { createContext, Script } from 'node:vm';
|
||||
import { compile, parse } from 'svelte/compiler';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const ast = parse(source, { modern: true });
|
||||
|
||||
function elementWithClass(nodes, className) {
|
||||
return nodes.find((node) => node.attributes?.some((attribute) =>
|
||||
attribute.name === 'class' && attribute.value[0]?.data === className
|
||||
));
|
||||
}
|
||||
|
||||
const aside = elementWithClass(ast.fragment.nodes, 'sidebar');
|
||||
const blocks = aside.fragment.nodes.filter((node) => node.type === 'IfBlock');
|
||||
const content = blocks.find((node) => elementWithClass(node.consequent.nodes, 'section'));
|
||||
const header = blocks.find((node) => elementWithClass(node.consequent.nodes, 'sidebar-header'));
|
||||
const toggle = elementWithClass(
|
||||
elementWithClass(header.consequent.nodes, 'sidebar-header').fragment.nodes,
|
||||
'collapse-btn'
|
||||
);
|
||||
|
||||
function expressionScript(expression) {
|
||||
assert.ok(expression, 'expected a component expression');
|
||||
return new Script(`(${source.slice(expression.start, expression.end)})`);
|
||||
}
|
||||
|
||||
// Execute the actual template conditions, not a copy of the collapse logic.
|
||||
const expressions = {
|
||||
contentVisible: expressionScript(content.test),
|
||||
collapsedClass: expressionScript(aside.attributes.find((attribute) =>
|
||||
attribute.type === 'ClassDirective' && attribute.name === 'collapsed'
|
||||
).expression),
|
||||
toggleVisible: expressionScript(header.test)
|
||||
};
|
||||
const toggleScript = expressionScript(
|
||||
toggle.attributes.find((attribute) => attribute.name === 'onclick').value.expression
|
||||
);
|
||||
|
||||
function sidebarContext(isMobile, stored) {
|
||||
const sidebarCollapsed = writable(stored);
|
||||
const context = createContext({
|
||||
isMobile,
|
||||
get $sidebarCollapsed() { return get(sidebarCollapsed); },
|
||||
set $sidebarCollapsed(value) { sidebarCollapsed.set(value); }
|
||||
});
|
||||
return { context, sidebarCollapsed };
|
||||
}
|
||||
|
||||
function evaluate(context) {
|
||||
return Object.fromEntries(Object.entries(expressions).map(([name, script]) =>
|
||||
[name, script.runInContext(context)]
|
||||
));
|
||||
}
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
platform: 'desktop', isMobile: false,
|
||||
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: true },
|
||||
collapsed: { contentVisible: false, collapsedClass: true, toggleVisible: true }
|
||||
},
|
||||
{
|
||||
platform: 'mobile', isMobile: true,
|
||||
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: false },
|
||||
collapsed: { contentVisible: true, collapsedClass: false, toggleVisible: false }
|
||||
}
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
for (const stored of [false, true]) {
|
||||
test(`${scenario.platform} navigation with stored sidebar_collapsed=${stored}`, () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, stored);
|
||||
|
||||
assert.deepEqual(evaluate(context), stored ? scenario.collapsed : scenario.expanded);
|
||||
assert.equal(get(sidebarCollapsed), stored, 'rendering must preserve the stored preference');
|
||||
});
|
||||
}
|
||||
|
||||
test(`${scenario.platform} navigation follows late false -> true -> false store updates`, () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, false);
|
||||
|
||||
assert.deepEqual(evaluate(context), scenario.expanded);
|
||||
sidebarCollapsed.set(true);
|
||||
assert.deepEqual(evaluate(context), scenario.collapsed);
|
||||
assert.equal(get(sidebarCollapsed), true, 'rendering must not reset the loaded preference');
|
||||
sidebarCollapsed.set(false);
|
||||
assert.deepEqual(evaluate(context), scenario.expanded);
|
||||
assert.equal(get(sidebarCollapsed), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('desktop collapse toggle still collapses and expands the sidebar', () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(false, false);
|
||||
const toggleSidebar = toggleScript.runInContext(context);
|
||||
|
||||
toggleSidebar();
|
||||
assert.equal(get(sidebarCollapsed), true);
|
||||
assert.deepEqual(evaluate(context), scenarios[0].collapsed);
|
||||
toggleSidebar();
|
||||
assert.equal(get(sidebarCollapsed), false);
|
||||
assert.deepEqual(evaluate(context), scenarios[0].expanded);
|
||||
});
|
||||
|
||||
test('Sidebar compiles for the client', () => {
|
||||
assert.doesNotThrow(() => compile(source, {
|
||||
filename: 'src/lib/components/Sidebar.svelte',
|
||||
generate: 'client'
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/components/SettingsPanel.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const initialization = source.match(
|
||||
/(const\s+themePresets\s*=\s*\[[\s\S]*?\];)[\s\S]*?(?=\bconst\s+accentPresets\s*=)/
|
||||
);
|
||||
assert.ok(initialization, 'theme presets initialization was not found');
|
||||
|
||||
// Evaluate only the trusted repository declaration and its initialization code.
|
||||
const unsorted = new Function(`${initialization[1]}\nreturn themePresets;`)();
|
||||
const presets = new Function(`${initialization[0]}\nreturn themePresets;`)();
|
||||
|
||||
test('pins System, Light, and Dark in that exact order', () => {
|
||||
assert.deepEqual(presets.slice(0, 3).map(({ id, label }) => ({ id, label })), [
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'light', label: 'Light' },
|
||||
{ id: 'dark', label: 'Dark' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('orders remaining built-in labels alphabetically, including Rosé Pine', () => {
|
||||
const labels = presets.slice(3).map((preset) => preset.label);
|
||||
assert.ok(labels.includes('Rosé Pine'));
|
||||
assert.deepEqual(labels, [...labels].sort());
|
||||
});
|
||||
|
||||
test('preserves every theme ID, label, and color without duplicates', () => {
|
||||
assert.equal(presets.length, unsorted.length);
|
||||
for (const themes of [unsorted, presets]) {
|
||||
assert.equal(new Set(themes.map((preset) => preset.id)).size, themes.length);
|
||||
assert.equal(new Set(themes.map((preset) => preset.label)).size, themes.length);
|
||||
}
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(presets.map((preset) => [preset.id, preset])),
|
||||
Object.fromEntries(unsorted.map((preset) => [preset.id, preset]))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const { restoreTitleHeading, stripTitleHeading } = await import(
|
||||
new URL('../src/lib/editor/titleVisibility.ts', import.meta.url)
|
||||
);
|
||||
const editorSource = await readFile(
|
||||
new URL('../src/lib/components/Editor.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
test('rich to source transition strips the restored title before display', () => {
|
||||
const start = editorSource.indexOf('if (isSource && !lastSourceMode) {');
|
||||
const end = editorSource.indexOf('} else if (!isSource && lastSourceMode)', start);
|
||||
|
||||
assert.notEqual(start, -1, 'rich to source transition was not found');
|
||||
assert.notEqual(end, -1, 'rich to source transition boundary was not found');
|
||||
assert.match(
|
||||
editorSource.slice(start, end),
|
||||
/sourceContent\s*=\s*stripTitleH1\(\s*editor\s*\?\s*editorToMarkdown\(\)\s*:\s*\(\$activeNote\?\.content\s*\?\?\s*''\)\s*\)\s*;/
|
||||
);
|
||||
});
|
||||
|
||||
test('source to rich transition preserves the hidden title on desktop and mobile', () => {
|
||||
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
|
||||
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
|
||||
const transition = editorSource.slice(start, end);
|
||||
|
||||
assert.notEqual(start, -1, 'source to rich transition was not found');
|
||||
assert.notEqual(end, -1, 'source to rich transition boundary was not found');
|
||||
assert.match(
|
||||
transition,
|
||||
/editor\.commands\.setContent\(\s*markdownToHtml\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*\)\s*;/
|
||||
);
|
||||
assert.match(transition, /createEditor\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*;/);
|
||||
});
|
||||
|
||||
test('source to rich transition preserves an empty source body on desktop and mobile', () => {
|
||||
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
|
||||
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
|
||||
const transition = editorSource.slice(start, end);
|
||||
const contentAssignments = [...transition.matchAll(/const content = ([^;]+);/g)]
|
||||
.map((match) => match[1].trim());
|
||||
|
||||
assert.deepEqual(contentAssignments, ['srcText', 'srcText']);
|
||||
assert.doesNotMatch(transition, /srcText\s*\|\|/);
|
||||
});
|
||||
|
||||
test('keeps a hidden title through source to rich to source and save', () => {
|
||||
const persisted = '# Note title\n\n## Something else\n\nBody\n';
|
||||
const body = '## Something else\n\nBody\n';
|
||||
|
||||
const initialSource = stripTitleHeading(persisted, 'Note title', true);
|
||||
const rich = stripTitleHeading(
|
||||
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
const toggledSource = stripTitleHeading(
|
||||
restoreTitleHeading(rich.markdown, rich.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(rich.markdown, body);
|
||||
assert.equal(toggledSource.markdown, body);
|
||||
assert.equal(restoreTitleHeading(toggledSource.markdown, toggledSource.hiddenTitle), persisted);
|
||||
});
|
||||
|
||||
test('keeps a title-only note visually empty and saves one title heading', () => {
|
||||
const initialSource = stripTitleHeading('# Note title\n', 'Note title', true);
|
||||
const rich = stripTitleHeading(
|
||||
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
const saved = restoreTitleHeading(rich.markdown, rich.hiddenTitle);
|
||||
|
||||
assert.equal(initialSource.markdown, '');
|
||||
assert.equal(rich.markdown, '');
|
||||
assert.equal((saved.match(/^# Note title$/gm) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test('replaces hidden title state when an unrelated note loads', () => {
|
||||
const markdown = '## Something else\n\nOther body\n';
|
||||
const start = editorSource.indexOf('function stripTitleH1(md: string): string {');
|
||||
const end = editorSource.indexOf('function restoreTitleH1(md: string): string {', start);
|
||||
|
||||
assert.deepEqual(stripTitleHeading(markdown, 'Another note', true), {
|
||||
markdown,
|
||||
hiddenTitle: null
|
||||
});
|
||||
assert.match(editorSource.slice(start, end), /hiddenTitleHeading\s*=\s*result\.hiddenTitle\s*;/);
|
||||
});
|
||||
|
||||
test('leaves the title visible when title hiding is disabled', () => {
|
||||
const persisted = '# Note title\n\n## Something else\n';
|
||||
|
||||
assert.deepEqual(stripTitleHeading(persisted, 'Note title', false), {
|
||||
markdown: persisted,
|
||||
hiddenTitle: null
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user