Compare commits

...
5 Commits
11 changed files with 565 additions and 73 deletions
+11 -6
View File
@@ -1,11 +1,16 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.3-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
A local markdown note-taking app built with Tauri, SvelteKit, and Rust. A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.2.1) ## Download (v1.2.3)
### Linux ### Linux
@@ -23,23 +28,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.3/HelixNotes_1.2.3_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_amd64.deb) (Ubuntu 22.04+) [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_x64-setup.exe) (Windows 10/11) [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_x64.dmg) (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android ### Android
[Download APK](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_android.apk) [Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_android.apk)
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.2.2", "version": "1.2.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+2 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.2" version = "1.2.4"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -3938,6 +3938,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http", "http",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.2" version = "1.2.4"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
@@ -35,7 +35,7 @@ tokio = { version = "1", features = ["full"] }
dirs = "6" dirs = "6"
regex = "1" regex = "1"
zip = { version = "2", default-features = false, features = ["deflate"] } zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3" futures = "0.3"
rayon = "1" rayon = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+5 -2
View File
@@ -287,8 +287,11 @@ pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String
} }
#[tauri::command] #[tauri::command]
pub fn rename_note(path: String, new_title: String) -> Result<String, String> { pub fn rename_note(state: State<'_, AppState>, path: String, new_title: String) -> Result<String, String> {
operations::rename_note(&path, &new_title) let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
drop(config);
operations::rename_note(&path, &new_title, &vault_path)
} }
#[tauri::command] #[tauri::command]
+94 -1
View File
@@ -149,7 +149,80 @@ pub fn run() {
commands::ai_ask, commands::ai_ask,
commands::get_install_type, commands::get_install_type,
commands::get_pending_open_file, commands::get_pending_open_file,
]); ])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
let path = request.uri().path().to_string();
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
tauri::http::Response::builder()
.status(400)
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
});
});
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
{ {
@@ -251,3 +324,23 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn percent_decode(input: &str) -> String {
let mut output = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(decoded) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
output.push(decoded);
i += 3;
continue;
}
}
output.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&output).to_string()
}
+138 -5
View File
@@ -276,7 +276,7 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (mut meta, _content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Always use filesystem timestamps on Android (faster than parsing date strings) // Always use filesystem timestamps on Android (faster than parsing date strings)
if let Ok(fs_meta) = fs::metadata(path) { if let Ok(fs_meta) = fs::metadata(path) {
@@ -294,11 +294,13 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let preview = frontmatter::extract_preview(&content, 120);
Ok(NoteEntry { Ok(NoteEntry {
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
meta, meta,
preview: String::new(), preview,
}) })
} }
@@ -623,13 +625,13 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
Ok(()) Ok(())
} }
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> { pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path); let src = Path::new(path);
if !src.exists() { if !src.exists() {
return Err("Note does not exist".to_string()); return Err("Note does not exist".to_string());
} }
// Update frontmatter // Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?; let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src let filename = src
.file_name() .file_name()
@@ -637,6 +639,10 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (mut meta, content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
let old_title = meta.title.clone();
let old_path_str = src.to_string_lossy().to_string();
// Update frontmatter
meta.title = new_title.to_string(); meta.title = new_title.to_string();
meta.modified = Utc::now(); meta.modified = Utc::now();
if meta.id.is_empty() { if meta.id.is_empty() {
@@ -653,7 +659,134 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
fs::rename(src, &new_path).map_err(|e| e.to_string())?; fs::rename(src, &new_path).map_err(|e| e.to_string())?;
} }
Ok(new_path.to_string_lossy().to_string()) let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
Ok(new_path_str)
}
/// Walk all .md files in the vault and update wikilink references after a note rename.
/// Updates both HTML data-attributes (data-path, data-title) and raw [[old_title]] references.
fn update_wikilinks_after_rename(
vault_path: &str,
old_path: &str,
new_path: &str,
old_title: &str,
new_title: &str,
) {
let vault = Path::new(vault_path);
let vault_prefix = format!("{}/", vault_path);
// Compute vault-relative path refs (without .md) for path-based wikilink refs
let old_rel_ref = old_path.strip_prefix(&vault_prefix)
.unwrap_or(old_path)
.strip_suffix(".md")
.unwrap_or(old_path);
let new_rel_ref = new_path.strip_prefix(&vault_prefix)
.unwrap_or(new_path)
.strip_suffix(".md")
.unwrap_or(new_path);
// Check if another note with the same old_title exists in the vault.
// If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
// because we can't tell which [[Old Title]] ref points to the renamed note
// vs. the other note with the same title.
let title_is_unique = !WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
if !p.is_file() || p.to_string_lossy().as_ref() == new_path {
return false;
}
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
return false;
}
let p_str = p.to_string_lossy();
if p_str.contains("/.helixnotes/") || p_str.contains("/.trash/") {
return false;
}
// Check if this note's filename (without .md) matches the old title
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case(old_title))
.unwrap_or(false)
});
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip the renamed note itself
if *path_str == *new_path { continue; }
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let mut result = content.clone();
// Notes are saved as markdown with [[ref]] or [[ref|display]] syntax.
// Update all wikilink forms that reference the renamed note:
// Title-based rules only apply when the old title is unique in the vault.
// If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace(
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace(
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace(
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
}
// Path-based rules are always safe (paths are unique).
if old_rel_ref != new_rel_ref {
// 4. Path-based ref: [[folder/Old Name]] → [[folder/New Name]]
result = result.replace(
&format!("[[{}]]", old_rel_ref),
&format!("[[{}]]", new_rel_ref),
);
// 5. Path-based ref with alias: [[folder/Old Name|display]] → [[folder/New Name|display]]
// Also update the alias if it matches the old title.
// e.g. [[folder/Old Name|Old Title]] → [[folder/New Name|New Title]]
if old_title != new_title {
result = result.replace(
&format!("[[{}|{}]]", old_rel_ref, old_title),
&format!("[[{}|{}]]", new_rel_ref, new_title),
);
}
// For aliases that DON'T match the old title, just update the ref part
result = result.replace(
&format!("[[{}|", old_rel_ref),
&format!("[[{}|", new_rel_ref),
);
}
if result != content {
let _ = fs::write(path, &result);
}
}
} }
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> { pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.2.2", "version": "1.2.4",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
@@ -24,7 +24,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost", "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": ["**/*", "/**", "**/.helixnotes/**"]
+222 -22
View File
@@ -742,8 +742,17 @@
let wikiLinkSelectedIndex = $state(0); let wikiLinkSelectedIndex = $state(0);
let wikiLinkTitlesCache = $state<NoteTitleEntry[]>([]); let wikiLinkTitlesCache = $state<NoteTitleEntry[]>([]);
let wikiLinkTypedByUser = false; let wikiLinkTypedByUser = false;
// Disambiguation state: when ]] auto-close finds multiple matches
let wikiLinkDisambigEntries = $state<NoteTitleEntry[] | null>(null);
let wikiLinkDisambigRef = $state<string | null>(null);
let wikiLinkDisambigDisplay = $state<string | null>(null);
// Navigation disambiguation: when clicking a wikilink with multiple matches
let wikiLinkNavDisambig = $state<{ entries: NoteTitleEntry[]; x: number; y: number } | null>(null);
let wikiLinkNavDisambigIndex = $state(0);
let wikiLinkFiltered = $derived.by(() => { let wikiLinkFiltered = $derived.by(() => {
// When disambiguating, show only the exact matches
if (wikiLinkDisambigEntries) return wikiLinkDisambigEntries;
if (!wikiLinkMenu) return wikiLinkTitlesCache; if (!wikiLinkMenu) return wikiLinkTitlesCache;
let q = wikiLinkMenu.query.toLowerCase(); let q = wikiLinkMenu.query.toLowerCase();
if (!q) return wikiLinkTitlesCache; if (!q) return wikiLinkTitlesCache;
@@ -757,6 +766,29 @@
); );
}); });
// Set of lowercase titles that appear more than once (for disambiguation display)
let wikiLinkDuplicateTitles = $derived.by(() => {
const counts = new Map<string, number>();
for (const e of wikiLinkTitlesCache) {
const key = e.title.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const dupes = new Set<string>();
for (const [key, count] of counts) {
if (count > 1) dupes.add(key);
}
return dupes;
});
function wikiLinkFolderPath(entry: NoteTitleEntry): string {
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot || !entry.path) return '';
const rel = entry.path.startsWith(vaultRoot + '/') ? entry.path.slice(vaultRoot.length + 1) : entry.path;
const parts = rel.split('/');
// Return parent folder(s), excluding the filename
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
}
async function refreshWikiLinkTitles() { async function refreshWikiLinkTitles() {
try { try {
wikiLinkTitlesCache = await getAllNoteTitles(); wikiLinkTitlesCache = await getAllNoteTitles();
@@ -768,6 +800,15 @@
function closeWikiLinkMenu() { function closeWikiLinkMenu() {
wikiLinkMenu = null; wikiLinkMenu = null;
wikiLinkSelectedIndex = 0; wikiLinkSelectedIndex = 0;
wikiLinkDisambigEntries = null;
wikiLinkDisambigRef = null;
wikiLinkDisambigDisplay = null;
}
function wikiLinkRelPath(entry: NoteTitleEntry): string | null {
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot || !entry.path || !entry.path.startsWith(vaultRoot + '/')) return null;
return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, '');
} }
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) { function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
@@ -777,9 +818,17 @@
const to = editor.state.selection.from; const to = editor.state.selection.from;
editor.chain().focus().deleteRange({ from, to }).run(); editor.chain().focus().deleteRange({ from, to }).run();
// Insert the wiki-link mark // Insert the wiki-link mark
// entry.title is the display text, originalRef (if provided) is the full reference (e.g. "note#heading") // For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
const displayText = entry.title; const displayText = entry.title;
const titleAttr = originalRef || entry.title; let titleAttr = originalRef || entry.title;
if (entry.path && wikiLinkDuplicateTitles.has(entry.title.toLowerCase())) {
const relPath = wikiLinkRelPath(entry);
if (relPath) {
// Preserve any #heading or ^block anchors from the original ref
const anchor = originalRef ? originalRef.replace(/^[^#^]*/, '') : '';
titleAttr = relPath + anchor;
}
}
tick().then(() => { tick().then(() => {
if (!editor) return; if (!editor) return;
editor.chain().focus() editor.chain().focus()
@@ -796,7 +845,12 @@
function executeWikiLinkCommand(index: number) { function executeWikiLinkCommand(index: number) {
const items = wikiLinkFiltered; const items = wikiLinkFiltered;
if (index < 0 || index >= items.length) return; if (index < 0 || index >= items.length) return;
insertWikiLink(items[index]); if (wikiLinkDisambigEntries) {
// In disambiguation mode: use stored display/ref
insertWikiLink({ ...items[index], title: wikiLinkDisambigDisplay || items[index].title }, wikiLinkDisambigRef || undefined);
} else {
insertWikiLink(items[index]);
}
} }
const WikiLink = TiptapMark.create({ const WikiLink = TiptapMark.create({
@@ -849,7 +903,7 @@
event.stopPropagation(); event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || ''; const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || ''; const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || '';
navigateToWikiLink(path, title); navigateToWikiLink(path, title, event as MouseEvent);
return true; return true;
} }
return false; return false;
@@ -917,9 +971,18 @@
const display = (pipeIdx >= 0 ? rawQuery.slice(pipeIdx + 1) : noteRef).trim(); const display = (pipeIdx >= 0 ? rawQuery.slice(pipeIdx + 1) : noteRef).trim();
// Strip #heading and ^block for title matching // Strip #heading and ^block for title matching
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === titleForLookup.toLowerCase()); const matches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === titleForLookup.toLowerCase());
if (match) { if (matches.length === 1) {
insertWikiLink({ ...match, title: display }, noteRef); insertWikiLink({ ...matches[0], title: display }, noteRef);
} else if (matches.length > 1) {
// Multiple matches — show disambiguation picker
// Keep the menu open but filter to only the matching entries
wikiLinkMenu = { ...wikiLinkMenu!, query: titleForLookup };
// Override filtered results to only show exact matches
wikiLinkDisambigEntries = matches;
wikiLinkDisambigRef = noteRef;
wikiLinkDisambigDisplay = display;
wikiLinkSelectedIndex = 0;
} else { } else {
// Insert as unresolved wiki-link (no path) // Insert as unresolved wiki-link (no path)
const menuFrom = wikiLinkMenu.from; const menuFrom = wikiLinkMenu.from;
@@ -991,22 +1054,57 @@
wikiLinkSelectedIndex = 0; wikiLinkSelectedIndex = 0;
} }
async function navigateToWikiLink(path: string, title: string) { async function navigateToWikiLink(path: string, title: string, clickEvent?: MouseEvent) {
// title may contain #heading or ^block anchors — strip for note lookup // title may contain #heading or ^block anchors — strip for note lookup
const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
if (!path) {
// Try path-based resolution first (for disambiguated refs like "folder/note")
const vaultRoot = $appConfig?.active_vault;
if (noteTitle.includes('/') && vaultRoot) {
const fullPath = vaultRoot + '/' + noteTitle + '.md';
const pathMatch = wikiLinkTitlesCache.find(e => e.path === fullPath);
if (pathMatch) {
path = pathMatch.path;
} else {
// Path not found — try title of last segment
const lastSegment = noteTitle.split('/').pop()!;
const segMatches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === lastSegment.toLowerCase());
if (segMatches.length === 1) path = segMatches[0].path;
else if (segMatches.length > 1) {
let x = clickEvent ? clickEvent.clientX : window.innerWidth / 2 - 140;
let y = clickEvent ? clickEvent.clientY + 8 : window.innerHeight / 2 - 100;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
if (y + 200 > window.innerHeight) y = Math.max(4, window.innerHeight - 200);
wikiLinkNavDisambig = { entries: segMatches, x, y };
wikiLinkNavDisambigIndex = 0;
return;
}
}
}
}
if (!path) { if (!path) {
// Unresolved link — try to find by title // Unresolved link — try to find by title
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === noteTitle.toLowerCase()); const matches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === noteTitle.toLowerCase());
if (match) { if (matches.length === 1) {
path = match.path; path = matches[0].path;
} else if (matches.length > 1) {
// Multiple matches — show disambiguation popup
let x = clickEvent ? clickEvent.clientX : window.innerWidth / 2 - 140;
let y = clickEvent ? clickEvent.clientY + 8 : window.innerHeight / 2 - 100;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
if (y + 200 > window.innerHeight) y = Math.max(4, window.innerHeight - 200);
wikiLinkNavDisambig = { entries: matches, x, y };
wikiLinkNavDisambigIndex = 0;
return;
} else { } else {
// Offer to create the note (use clean title, not the anchor ref) // Offer to create the note (use clean title, not the anchor ref)
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
const notebookRel = $activeNotePath const notebookRel = $activeNotePath
? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/') ? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/')
: null; : null;
try { try {
const { createNote } = await import('$lib/api'); const { createNote } = await import('$lib/api');
const newNote = await createNote(notebookRel || null, noteTitle); const newNote = await createNote(notebookRel || null, cleanTitle);
// Navigate to the new note // Navigate to the new note
const content = await readNote(newNote.path); const content = await readNote(newNote.path);
$activeNote = { ...content, content: content.content }; $activeNote = { ...content, content: content.content };
@@ -1026,6 +1124,17 @@
} }
} }
async function navigateToWikiLinkDirect(entry: NoteTitleEntry) {
wikiLinkNavDisambig = null;
try {
const content = await readNote(entry.path);
$activeNote = { ...content, content: content.content };
$activeNotePath = entry.path;
} catch (e) {
console.error('Failed to navigate to wiki-link:', e);
}
}
const textColors = [ const textColors = [
{ name: 'Default', value: '' }, { name: 'Default', value: '' },
{ name: 'Red', value: '#ef4444' }, { name: 'Red', value: '#ef4444' },
@@ -1071,10 +1180,19 @@
]; ];
function resolveImageSrc(src: string): string { function resolveImageSrc(src: string): string {
// Already an absolute URL or data URI // Already a proxied, asset, data, or blob URL
if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('asset:') || src.startsWith('blob:')) { if (src.startsWith('data:') || src.startsWith('asset:') || src.startsWith('blob:') || src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
return src; return src;
} }
// Already an asset-localhost URL
if (src.startsWith('http://asset.localhost') || src.startsWith('https://asset.localhost')) {
return src;
}
// External http/https URLs: proxy through Tauri's imgproxy protocol
// to bypass WebKitGTK restrictions on loading external resources
if (src.startsWith('http://') || src.startsWith('https://')) {
return convertFileSrc(src, 'imgproxy');
}
// Decode percent-encoding (%20 → space, etc.) for filesystem resolution // Decode percent-encoding (%20 → space, etc.) for filesystem resolution
let decoded = decodeURIComponent(src); let decoded = decodeURIComponent(src);
// Fix multiple leading slashes (from broken saves) // Fix multiple leading slashes (from broken saves)
@@ -1767,6 +1885,15 @@
function stripAssetSrc(src: string): string { function stripAssetSrc(src: string): string {
// blob: URLs are not persistable — they were temporary browser references // blob: URLs are not persistable — they were temporary browser references
if (src.startsWith('blob:')) return ''; if (src.startsWith('blob:')) return '';
// Convert imgproxy:// URLs back to original external URLs for saving
if (src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
try {
const url = new URL(src);
return decodeURIComponent(url.pathname.substring(1));
} catch {
return src;
}
}
// Convert asset:// URLs back to relative paths for saving // Convert asset:// URLs back to relative paths for saving
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src; if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src;
let absPath = ''; let absPath = '';
@@ -1917,8 +2044,28 @@
const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim(); const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim();
// Strip #heading and ^block anchors for title matching // Strip #heading and ^block anchors for title matching
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
// Try to resolve the title to a path from cache // Try to resolve: first by vault-relative path (for disambiguated links), then by title
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === titleForLookup.toLowerCase()); const vaultRoot = $appConfig?.active_vault ?? '';
let match: NoteTitleEntry | undefined;
if (titleForLookup.includes('/') && vaultRoot) {
// Ref contains a path — resolve by matching vault-relative path
const fullPath = vaultRoot + '/' + titleForLookup + '.md';
match = wikiLinkTitlesCache.find(e => e.path === fullPath);
}
if (!match) {
// Fallback: resolve by title (use the last segment if path-based)
const titleOnly = titleForLookup.includes('/') ? titleForLookup.split('/').pop()! : titleForLookup;
const titleLower = titleOnly.toLowerCase();
const titleMatches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === titleLower);
if (titleMatches.length === 1) {
match = titleMatches[0];
} else if (titleMatches.length > 1) {
// Multiple matches — prefer the shallowest path (closest to vault root)
match = titleMatches.reduce((a, b) =>
a.path.split('/').length <= b.path.split('/').length ? a : b
);
}
}
const path = match ? match.path : ''; const path = match ? match.path : '';
return `<span data-wiki-link data-path="${escapeHtml(path)}" data-title="${escapeHtml(noteRef)}" class="wiki-link">${escapeHtml(display)}</span>`; return `<span data-wiki-link data-path="${escapeHtml(path)}" data-title="${escapeHtml(noteRef)}" class="wiki-link">${escapeHtml(display)}</span>`;
}); });
@@ -2339,7 +2486,7 @@
event.stopPropagation(); event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || ''; const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || ''; const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || '';
navigateToWikiLink(path, title); navigateToWikiLink(path, title, event);
return; return;
} }
@@ -3266,6 +3413,8 @@
? { ...n, path: newPath, relative_path: n.relative_path.replace(/[^/]+$/, newTitle + '.md'), meta: { ...n.meta, title: newTitle } } ? { ...n, path: newPath, relative_path: n.relative_path.replace(/[^/]+$/, newTitle + '.md'), meta: { ...n.meta, title: newTitle } }
: n : n
)); ));
// Refresh wiki-link titles cache so links resolve to renamed note
refreshWikiLinkTitles();
} catch (err) { } catch (err) {
console.error('Failed to rename note file:', err); console.error('Failed to rename note file:', err);
notes.update(list => list.map(n => notes.update(list => list.map(n =>
@@ -4358,7 +4507,7 @@
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button> <button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button> <button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button> <button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
{#if !isMobile} {#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')}
<span class="img-toolbar-sep"></span> <span class="img-toolbar-sep"></span>
<button onclick={copyImageToClipboard} title="Copy image"> <button onclick={copyImageToClipboard} title="Copy image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
@@ -4467,10 +4616,15 @@
class="wiki-link-item" class="wiki-link-item"
class:selected={i === wikiLinkSelectedIndex} class:selected={i === wikiLinkSelectedIndex}
onmouseenter={() => wikiLinkSelectedIndex = i} onmouseenter={() => wikiLinkSelectedIndex = i}
onmousedown={(e) => { e.preventDefault(); insertWikiLink(entry); }} onmousedown={(e) => { e.preventDefault(); if (wikiLinkDisambigEntries) { insertWikiLink({ ...entry, title: wikiLinkDisambigDisplay || entry.title }, wikiLinkDisambigRef || undefined); } else { insertWikiLink(entry); } }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<span class="wiki-link-title">{entry.title}</span> <span class="wiki-link-title-col">
<span class="wiki-link-title">{entry.title}</span>
{#if wikiLinkDuplicateTitles.has(entry.title.toLowerCase())}
<span class="wiki-link-folder">{wikiLinkFolderPath(entry) || '(vault root)'}</span>
{/if}
</span>
</button> </button>
{/each} {/each}
{/if} {/if}
@@ -4478,6 +4632,30 @@
</div> </div>
{/if} {/if}
{#if wikiLinkNavDisambig}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-overlay" onclick={() => wikiLinkNavDisambig = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px" onclick={(e) => e.stopPropagation()}>
<div class="wiki-link-disambig-header">Multiple notes found — choose one:</div>
{#each wikiLinkNavDisambig.entries as entry, i}
<button
class="wiki-link-item"
class:selected={i === wikiLinkNavDisambigIndex}
onmouseenter={() => wikiLinkNavDisambigIndex = i}
onmousedown={(e) => { e.preventDefault(); navigateToWikiLinkDirect(entry); }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<span class="wiki-link-title-col">
<span class="wiki-link-title">{entry.title}</span>
<span class="wiki-link-folder">{wikiLinkFolderPath(entry) || '(vault root)'}</span>
</span>
</button>
{/each}
</div>
</div>
{/if}
{#if aiMenu} {#if aiMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}> <div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}>
@@ -5248,7 +5426,7 @@
color: var(--text-primary); color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px); font-size: var(--editor-font-size, 14px);
line-height: var(--editor-line-height, 1.6); line-height: 1.3;
resize: none; resize: none;
outline: none; outline: none;
padding: 0; padding: 0;
@@ -5276,7 +5454,7 @@
padding-top: 8px; padding-top: 8px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px); font-size: var(--editor-font-size, 14px);
line-height: var(--editor-line-height, 1.6); line-height: 1.3;
color: var(--text-secondary); color: var(--text-secondary);
opacity: 0.5; opacity: 0.5;
text-align: right; text-align: right;
@@ -6835,12 +7013,34 @@
color: var(--accent); color: var(--accent);
} }
.wiki-link-title-col {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.wiki-link-title { .wiki-link-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.wiki-link-folder {
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wiki-link-disambig-header {
padding: 8px 10px 4px;
font-size: 11px;
color: var(--text-tertiary);
font-weight: 500;
}
/* ═══ MOBILE (class-based, not media-query, for Android high-DPI) ═══ */ /* ═══ MOBILE (class-based, not media-query, for Android high-DPI) ═══ */
.editor-container.mobile { .editor-container.mobile {
height: 100%; height: 100%;
+70 -13
View File
@@ -44,6 +44,8 @@
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false);
/** Derive tags from current $notes store (avoids re-scanning files on mobile) */ /** Derive tags from current $notes store (avoids re-scanning files on mobile) */
function deriveTagsFromNotes() { function deriveTagsFromNotes() {
@@ -154,6 +156,7 @@
selectedPaths = new Set(); selectedPaths = new Set();
lastClickedPath = null; lastClickedPath = null;
batchMovePicker = false; batchMovePicker = false;
multiSelectMode = false;
} }
// Clear selection and reset scroll when view/notebook changes // Clear selection and reset scroll when view/notebook changes
@@ -556,8 +559,8 @@
e.preventDefault(); e.preventDefault();
return; return;
} }
// Mobile: if in selection mode, tap toggles selection // Mobile: if in selection mode (or Android multi-select mode), tap toggles selection
if (isMobile && selectedPaths.size > 0) { if (isMobile && (selectedPaths.size > 0 || multiSelectMode)) {
const next = new Set(selectedPaths); const next = new Set(selectedPaths);
if (next.has(note.path)) { if (next.has(note.path)) {
next.delete(note.path); next.delete(note.path);
@@ -693,6 +696,13 @@
<div class="list-header"> <div class="list-header">
<span class="list-title">{viewTitle}</span> <span class="list-title">{viewTitle}</span>
<div class="list-actions"> <div class="list-actions">
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" />{#if multiSelectMode}<polyline points="6 6.5 7 7.5 9 5.5" stroke-width="2" /><polyline points="6 17.5 7 18.5 9 16.5" stroke-width="2" />{/if}<line x1="14" y1="6.5" x2="21" y2="6.5" /><line x1="14" y1="17.5" x2="21" y2="17.5" />
</svg>
</button>
{/if}
<button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}"> <button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /> <line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
@@ -717,6 +727,7 @@
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
{:else} {:else}
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button> <button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
<button class="selection-action danger" onclick={handleBatchDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchDelete}>Delete</button>
{/if} {/if}
<button class="selection-close" onclick={clearSelection} title="Clear selection"> <button class="selection-close" onclick={clearSelection} title="Clear selection">
@@ -755,6 +766,50 @@
</div> </div>
{/if} {/if}
{#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}>
<svg width="12" height="12" 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>
</div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
placeholder="Add tag to all..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/>
</div>
{#if tagEditTags.length > 0}
<div class="tag-edit-list">
{#each tagEditTags as tag}
<div class="tag-edit-item">
<span class="tag-edit-name">#{tag}</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>
</div>
{/each}
</div>
{:else}
<div class="context-empty">No common tags</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $sortedNotes.length === 0} {#if $sortedNotes.length === 0}
<div class="empty-state"> <div class="empty-state">
@@ -793,10 +848,10 @@
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'} class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'} class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
ontouchstart={(e) => { if (isMobile) handleTouchStart(e, note); }} ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile) handleTouchMove(e); }} ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile) handleTouchEnd(); }} ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
ontouchcancel={() => { if (isMobile) handleTouchEnd(); }} ontouchcancel={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
const pos = clampMenu(e.clientX, e.clientY); const pos = clampMenu(e.clientX, e.clientY);
@@ -846,13 +901,6 @@
}} }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }} ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
> >
{#if isMobile && selectedPaths.size > 0}
<div class="mobile-select-check" class:checked={selectedPaths.has(note.path)}>
{#if selectedPaths.has(note.path)}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</div>
{/if}
{#if compact} {#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
<span class="note-title"> <span class="note-title">
@@ -1172,6 +1220,11 @@
color: var(--text-primary); color: var(--text-primary);
} }
.icon-btn.active-toggle {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.list-content { .list-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1721,6 +1774,9 @@
.note-list.mobile .selection-bar { .note-list.mobile .selection-bar {
padding: 8px 16px; padding: 8px 16px;
border-radius: 12px;
margin: 4px 8px;
border-bottom: none;
} }
.note-list.mobile .selection-count { .note-list.mobile .selection-count {
@@ -1731,6 +1787,7 @@
padding: 6px 14px; padding: 6px 14px;
font-size: 13px; font-size: 13px;
min-height: 36px; min-height: 36px;
border-radius: 10px;
} }
.note-list.mobile .context-menu { .note-list.mobile .context-menu {
+18 -18
View File
@@ -1,20 +1,20 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
} },
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //
// To make changes to top-level options such as include and exclude, we recommend extending // To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
} }