Compare commits

...
14 Commits
Author SHA1 Message Date
Yuri Karamian 3607cf1b3f Merge branch 'main' into 'main'
feat: add custom tag icons and colors

See merge request ArkHost/HelixNotes!9
2026-09-21 09:08:33 +00:00
Blake Boone 245cdacb68 feat: add custom tag icons and colors 2026-09-21 09:08:33 +00:00
Yuri Karamian 2983e3db29 Merge branch 'main' into 'main'
Merge branch 'fix/sort-theme-list-alphabetically' into 'main'

Closes #250

See merge request ArkHost/HelixNotes!7
2026-09-17 12:30:52 +00:00
Shawn cc7c9e2b49 Merge branch HelixNotes:main into main 2026-09-15 16:09:56 +00:00
Shawn 2240231f61 Merge branch 'fix/sort-theme-list-alphabetically' into 'main'
fix: sort theme list alphabetically

See merge request locodarwin/HelixNotes!1
2026-09-15 15:36:44 +00:00
Yuri Karamian b2d0c7175e test: cover built-in theme preset ordering 2026-09-15 15:04:51 +02:00
Shawn Olson 1bc0026731 fix: sort theme list alphabetically 2026-09-14 14:42:06 -07:00
Yuri Karamian 41c0e4c993 fix: preserve Linux window title and configuration 2026-09-10 16:50:54 +02:00
Yuri Karamian 34a0f9efa3 fix: keep mobile navigation visible with collapsed desktop sidebar 2026-09-06 22:46:11 +02:00
Yuri Karamian 9d92489586 Merge branch 'fix/macos-escape-overlay-fullscreen' into 'main'
Prevent Escape from exiting fullscreen when closing overlays

See merge request ArkHost/HelixNotes!4
2026-09-04 13:00:47 +00:00
Vijay c45efb60ab Prevent Escape from exiting fullscreen when closing overlays 2026-09-04 13:00:46 +00:00
Yuri Karamian 0d11fd01dd fix: preserve hidden note title across editor modes 2026-09-04 14:46:30 +02:00
Yuri Karamian 0aaa05ce5c Merge branch 'fix/context-menu-paste-image' into 'main'
fix: paste images from the editor context menu

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