Compare commits

...
5 Commits
21 changed files with 1266 additions and 393 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
# 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) [![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.5-green)](https://codeberg.org/ArkHost/HelixNotes/releases) [![Latest Release](https://img.shields.io/badge/release-v1.2.7-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com) [![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)]() [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,7 @@ 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.5) ## Download (v1.2.7)
### Linux ### Linux
@@ -28,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.5/HelixNotes_1.2.5_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.7/HelixNotes_1.2.7_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_amd64.deb) (Ubuntu 22.04+) [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_x64-setup.exe) (Windows 10/11) [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_x64.dmg) (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android ### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_android.apk) [Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_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.6", "version": "1.2.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.5" version = "1.2.8"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.5" version = "1.2.8"
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"
+88 -25
View File
@@ -257,6 +257,14 @@ pub fn save_note(
drop(config); drop(config);
operations::save_note(&path, &meta, &body)?; operations::save_note(&path, &meta, &body)?;
// Re-index note so search picks up changes immediately
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.index_note(&path);
}
}
Ok(()) Ok(())
} }
@@ -422,9 +430,14 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path); let vault = std::path::Path::new(vault_path);
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing) // Pass 1: collect ALL notes as nodes (no title deduplication — every note must appear)
let mut graph_nodes = Vec::new(); let mut graph_nodes = Vec::new();
let mut title_to_idx: HashMap<String, usize> = HashMap::new(); // title → list of node indices (one title can map to multiple notes in different folders)
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
// absolute path → idx (for fast active-note lookup)
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new(); let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new(); let mut contents: Vec<String> = Vec::new();
@@ -454,12 +467,18 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
path.file_stem().unwrap_or_default().to_string_lossy().to_string() path.file_stem().unwrap_or_default().to_string_lossy().to_string()
}); });
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len(); let idx = graph_nodes.len();
title_to_idx.insert(title_lower, idx); let title_lower = title.to_lowercase();
title_to_idxs.entry(title_lower).or_default().push(idx);
path_to_idx.insert(path_str.clone(), idx);
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension("");
let rel_lower = rel_no_ext.to_string_lossy().to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx);
}
graph_nodes.push(crate::types::GraphNode { graph_nodes.push(crate::types::GraphNode {
title, title,
path: path_str, path: path_str,
@@ -468,8 +487,22 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
} }
// Pass 2: extract edges from wiki-links (inline scan, no regex) // Pass 2: extract edges from wiki-links (inline scan, no regex)
let mut edges = Vec::new(); // edge_map: (src, tgt) → index in edges vec, used to detect reverse links
let mut edge_set: HashSet<(usize, usize)> = HashSet::new(); let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
if src == tgt { return; }
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
// Reverse direction already exists — mark it as bidirectional
edges[rev_idx].bidirectional = true;
} else {
let idx = edges.len();
edge_map.insert((src, tgt), idx);
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
}
};
for (source_idx, body) in contents.iter().enumerate() { for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes(); let bytes = body.as_bytes();
@@ -490,11 +523,21 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let link = link.split('^').next().unwrap_or(link); let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase(); let link = link.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) { // 1. Try vault-relative path match (e.g. "arkhost/note name")
if target_idx != source_idx { if let Some(&target_idx) = relpath_to_idx.get(&link) {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) }; add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
if edge_set.insert(key) { } else if let Some(targets) = title_to_idxs.get(&link) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx }); // 2. Title match — connect to all notes with this title
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} else if link.contains('/') {
// 3. Path-based ref: try last segment as title fallback
if let Some(seg) = link.rsplit('/').next() {
if let Some(targets) = title_to_idxs.get(seg) {
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} }
} }
} }
@@ -1362,34 +1405,54 @@ fn resolve_wiki_ref(
reference.to_string() reference.to_string()
} }
// ── Open file with system default handler ── // ── Open file/URL with system default handler ──
#[tauri::command] fn xdg_open(arg: &str) -> Result<(), String> {
pub fn open_file(path: String) -> Result<(), String> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
std::process::Command::new("xdg-open") let mut cmd = std::process::Command::new("xdg-open");
.arg(&path) cmd.arg(arg);
.spawn() // Clear AppImage environment so child processes find host binaries
.map_err(|e| format!("Failed to open {}: {}", path, e))?; // (e.g. gio-launch-desktop on GNOME)
if std::env::var("APPIMAGE").is_ok() {
cmd.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
cmd.env("PATH", original_path);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
std::process::Command::new("open") std::process::Command::new("open")
.arg(&path) .arg(arg)
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
std::process::Command::new("cmd") std::process::Command::new("cmd")
.args(["/C", "start", "", &path]) .args(["/C", "start", "", arg])
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
Ok(()) Ok(())
} }
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
xdg_open(&path)
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
xdg_open(&url)
}
#[tauri::command] #[tauri::command]
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> { pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?; std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
+1
View File
@@ -137,6 +137,7 @@ pub fn run() {
commands::get_vault_stats, commands::get_vault_stats,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::create_backup, commands::create_backup,
commands::list_backups, commands::list_backups,
+32 -9
View File
@@ -9,9 +9,9 @@ use tantivy::collector::TopDocs;
use tantivy::directory::MmapDirectory; use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use tantivy::directory::RamDirectory; use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser; use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
use tantivy::schema::*; use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument}; use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir; use walkdir::WalkDir;
pub struct SearchIndex { pub struct SearchIndex {
@@ -152,14 +152,37 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?; let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); let searcher = reader.searcher();
let query_parser = QueryParser::for_index( let fields = vec![self.title_field, self.body_field, self.tags_field];
&self.index, let terms: Vec<String> = query_str
vec![self.title_field, self.body_field, self.tags_field], .split_whitespace()
); .map(|t| t.to_lowercase())
.collect();
let query = query_parser // For each search term, create prefix + fuzzy queries across all fields (OR),
.parse_query(query_str) // then AND all terms together
.map_err(|e| e.to_string())?; let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter()
.map(|term| {
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter()
.flat_map(|&field| {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
true,
));
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
})
.collect();
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
(Occur::Must, combined)
})
.collect();
let query = BooleanQuery::new(term_queries);
let top_docs = searcher let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit)) .search(&query, &TopDocs::with_limit(limit))
+1
View File
@@ -285,4 +285,5 @@ pub struct GraphNode {
pub struct GraphEdge { pub struct GraphEdge {
pub source: usize, pub source: usize,
pub target: usize, pub target: usize,
pub bidirectional: bool,
} }
+1 -1
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.6", "version": "1.2.8",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+4
View File
@@ -267,6 +267,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path }); return invoke("open_file", { path });
} }
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
export async function copyFileTo( export async function copyFileTo(
source: string, source: string,
destination: string, destination: string,
+26 -35
View File
@@ -34,8 +34,10 @@
tags, tags,
notes, notes,
viewMode, viewMode,
activeTag,
updateAvailable as globalUpdateAvailable, updateAvailable as globalUpdateAvailable,
settingsTab settingsTab,
navHistory
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
@@ -58,9 +60,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null; let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number { function parseFrequencyMs(freq: string): number {
switch (freq) { switch (freq) {
@@ -91,32 +91,19 @@
// Track note navigation in history stack // Track note navigation in history stack
$effect(() => { $effect(() => {
const path = $activeNotePath; const path = $activeNotePath;
if (navigatingFromHistory) { if (path) navHistory.push(path);
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
}); });
function navigateHistory(direction: -1 | 1) { function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction; const path = navHistory.go(direction);
if (newIndex < 0 || newIndex >= noteHistory.length) return; if (!path) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => { readNote(path).then((content) => {
editor?.flushSave(); editor?.flushSave();
$activeNote = content; $activeNote = content;
$activeNotePath = path; $activeNotePath = path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(path, content.content); editor?.loadNote(path, content.content);
}).catch(() => { }).catch(() => {});
// Note may have been deleted, ignore
});
} }
async function handleOpenFile(filePath: string) { async function handleOpenFile(filePath: string) {
@@ -252,6 +239,7 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') { if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault(); e.preventDefault();
navigateHistory(-1); navigateHistory(-1);
@@ -262,11 +250,12 @@
navigateHistory(1); navigateHistory(1);
return; return;
} }
if (mod && !e.shiftKey && e.key === 'n') { if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
createAndFocusNote(); createAndFocusNote();
return;
} }
if (mod && e.shiftKey && e.key === 'N') { if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark'; const next = isDark ? 'light' : 'dark';
@@ -274,12 +263,12 @@
setTheme(next); setTheme(next);
return; return;
} }
if (mod && e.shiftKey && e.key === 'F') { if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
$showSearch = true; $showSearch = true;
return; return;
} }
if (mod && !e.shiftKey && e.key === 'f') { if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath) { if ($activeNotePath) {
editor?.openNoteSearch(); editor?.openNoteSearch();
@@ -288,23 +277,25 @@
} }
return; return;
} }
if (mod && e.key === 'p') { if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault(); e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
return;
} }
if (mod && e.key === 's') { if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) { if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault(); e.preventDefault();
editor?.addLinkFromToolbar(); editor?.addLinkFromToolbar();
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
if (mod && e.shiftKey && e.key === 'W') { if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath && $activeNote) { if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title); openNoteWindow($activeNotePath, $activeNote.meta.title);
@@ -315,12 +306,12 @@
$sidebarCollapsed = !$sidebarCollapsed; $sidebarCollapsed = !$sidebarCollapsed;
return; return;
} }
if (mod && e.shiftKey && e.key === 'E') { if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault(); e.preventDefault();
$focusMode = !$focusMode; $focusMode = !$focusMode;
return; return;
} }
if (mod && e.shiftKey && e.key === 'R') { if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault(); e.preventDefault();
$readOnly = !$readOnly; $readOnly = !$readOnly;
return; return;
@@ -489,7 +480,7 @@
{#if $mobileView === 'sidebar'} {#if $mobileView === 'sidebar'}
HelixNotes HelixNotes
{:else} {:else}
{$activeNotebook?.name || 'All Notes'} {#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 === 'trash'}Trash{:else}All Notes{/if}
{/if} {/if}
</span> </span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'} {#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -593,7 +584,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note"> <button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
@@ -658,7 +649,7 @@
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
+413 -25
View File
@@ -29,16 +29,15 @@
import katex from 'katex'; import katex from 'katex';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { Extension, Node as TiptapNode, Mark as TiptapMark, mergeAttributes } from '@tiptap/core'; import { Extension, Node as TiptapNode, Mark as TiptapMark, mergeAttributes } from '@tiptap/core';
import { Plugin, PluginKey, EditorState } from '@tiptap/pm/state'; import { Plugin, PluginKey, EditorState, TextSelection } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view'; import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { DOMSerializer } from '@tiptap/pm/model'; import { DOMSerializer } from '@tiptap/pm/model';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { openFile, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app'; import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api'; import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types'; import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
@@ -177,9 +176,10 @@
{ label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: () => editor?.chain().focus().toggleTaskList().run() }, { label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: () => editor?.chain().focus().toggleTaskList().run() },
{ label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() }, { label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() },
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() }, { label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
{ label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => editor?.chain().focus().setDetails().run() }, { label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => insertDetails() },
{ label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } }, { label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } },
{ label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() }, { label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() },
{ label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="9" x2="22" y2="9" stroke-dasharray="4 2"/><line x1="2" y1="15" x2="22" y2="15" stroke-dasharray="4 2"/><path d="M6 5v4M18 5v4M6 15v4M18 15v4"/></svg>', action: () => editor?.chain().focus().insertContent({ type: 'pageBreak' }).run() },
]; ];
} }
@@ -214,6 +214,7 @@
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null); let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
let copyToast = $state<'copying' | 'done' | null>(null); let copyToast = $state<'copying' | 'done' | null>(null);
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : ''); let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let noteFolder = $derived(noteRelativePath ? noteRelativePath.substring(0, noteRelativePath.lastIndexOf('/')) : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
@@ -389,6 +390,27 @@
}, },
}); });
const PageBreak = TiptapNode.create({
name: 'pageBreak',
group: 'block',
atom: true,
parseHTML() {
return [
{ tag: 'div[data-page-break]' },
{
tag: 'div',
getAttrs: (el: HTMLElement) => {
const style = el.getAttribute('style') || '';
return style.includes('page-break-after') ? {} : false;
},
},
];
},
renderHTML() {
return ['div', { 'data-page-break': 'true', style: 'page-break-after: always; break-after: page;', class: 'page-break' }];
},
});
let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null); let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null);
function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) { function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) {
@@ -497,6 +519,30 @@
}, },
}); });
// Tab inserts a tab character in plain paragraphs/headings.
// Priority 50 < default 100, so list/task/table/codeblock extensions handle Tab first for their own nodes.
const TabIndent = Extension.create({
name: 'tabIndent',
priority: 50,
addKeyboardShortcuts() {
return {
Tab: () => {
const sel = this.editor.state.selection;
const from = sel.$from;
const node = from.node();
if (node.type.name !== 'paragraph' && node.type.name !== 'heading') return false;
// Don't intercept if inside a list or task item (their extensions handle Tab first,
// but guard here too in case of priority edge cases)
for (let d = from.depth - 1; d > 0; d--) {
const name = from.node(d).type.name;
if (name === 'listItem' || name === 'taskItem') return false;
}
return this.editor.commands.insertContent('\t');
},
};
},
});
const CodeBlockLanguageSelect = Extension.create({ const CodeBlockLanguageSelect = Extension.create({
name: 'codeBlockLanguageSelect', name: 'codeBlockLanguageSelect',
addGlobalAttributes() { addGlobalAttributes() {
@@ -597,6 +643,22 @@
slashTableHover = { rows: 0, cols: 0 }; slashTableHover = { rows: 0, cols: 0 };
} }
$effect(() => {
if (!slashMenu || slashSelectedIndex < 0) return;
slashSelectedIndex; // track
tick().then(() => {
document.querySelector('.slash-menu .slash-menu-item.selected')?.scrollIntoView({ block: 'nearest' });
});
});
$effect(() => {
if (!wikiLinkMenu || wikiLinkSelectedIndex < 0) return;
wikiLinkSelectedIndex; // track
tick().then(() => {
document.querySelector('.wiki-link-menu .wiki-link-item.selected')?.scrollIntoView({ block: 'nearest' });
});
});
function updateSlashMenu() { function updateSlashMenu() {
const wasSlashTyped = slashTypedByUser; const wasSlashTyped = slashTypedByUser;
slashTypedByUser = false; slashTypedByUser = false;
@@ -639,9 +701,11 @@
// Keep menu within viewport (account for virtual keyboard on mobile) // Keep menu within viewport (account for virtual keyboard on mobile)
if (x + 240 > window.innerWidth) x = window.innerWidth - 250; if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight; const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300); const menuHeight = 300;
let y = coords.bottom + 4;
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
if (y < 4) y = 4;
slashMenu = { x, y, query, from, to }; slashMenu = { x, y, query, from, to };
slashSelectedIndex = 0; slashSelectedIndex = 0;
@@ -761,9 +825,22 @@
if (pipeIdx >= 0) q = q.slice(0, pipeIdx); if (pipeIdx >= 0) q = q.slice(0, pipeIdx);
q = q.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); q = q.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
if (!q) return wikiLinkTitlesCache; if (!q) return wikiLinkTitlesCache;
return wikiLinkTitlesCache.filter(entry => // Score: 0 = exact, 1 = starts-with, 2 = word-start, 3 = contains
entry.title.toLowerCase().includes(q) const scored = wikiLinkTitlesCache
); .map(entry => {
const t = entry.title.toLowerCase();
let score: number;
if (t === q) score = 0;
else if (t.startsWith(q)) score = 1;
else if (t.includes(' ' + q) || t.includes('-' + q)) score = 2;
else if (t.includes(q)) score = 3;
else score = -1;
return { entry, score };
})
.filter(x => x.score >= 0)
.sort((a, b) => a.score - b.score)
.map(x => x.entry);
return scored;
}); });
// Set of lowercase titles that appear more than once (for disambiguation display) // Set of lowercase titles that appear more than once (for disambiguation display)
@@ -1047,9 +1124,11 @@
const coords = editor.view.coordsAtPos(from); const coords = editor.view.coordsAtPos(from);
let x = coords.left; let x = coords.left;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290; if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight; const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300); const menuHeight = 360;
let y = coords.bottom + 4;
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
if (y < 4) y = 4;
wikiLinkMenu = { x, y, query, from }; wikiLinkMenu = { x, y, query, from };
wikiLinkSelectedIndex = 0; wikiLinkSelectedIndex = 0;
} }
@@ -1377,6 +1456,18 @@
}); });
} }
async function editorNavigateHistory(direction: -1 | 1) {
const path = navHistory.go(direction);
if (!path) return;
flushSave();
try {
const content = await readNote(path);
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
} catch {}
}
/** Flush unsaved editor content to disk (synchronous serialize + fire-and-forget save). /** Flush unsaved editor content to disk (synchronous serialize + fire-and-forget save).
* Call BEFORE updating $activeNote/$activeNotePath stores when switching notes. */ * Call BEFORE updating $activeNote/$activeNotePath stores when switching notes. */
export function flushSave() { export function flushSave() {
@@ -1404,9 +1495,11 @@
const shouldBeReadOnly = isNewNote ? false : ($appConfig?.default_view_mode ?? false); const shouldBeReadOnly = isNewNote ? false : ($appConfig?.default_view_mode ?? false);
$readOnly = shouldBeReadOnly; $readOnly = shouldBeReadOnly;
if (editor) editor.setEditable(!shouldBeReadOnly); if (editor) editor.setEditable(!shouldBeReadOnly);
const editorBody = editorElement?.closest('.editor-body') as HTMLElement | null;
if ($sourceMode) { if ($sourceMode) {
sourceContent = stripTitleH1(content); sourceContent = stripTitleH1(content);
resetSourceHistory(sourceContent); resetSourceHistory(sourceContent);
if (editorBody) editorBody.scrollTop = 0;
isLoadingNote = false; isLoadingNote = false;
} else if (editorElement && editor) { } else if (editorElement && editor) {
// Editor already exists, just swap content // Editor already exists, just swap content
@@ -1415,8 +1508,19 @@
editor.commands.setContent(html); editor.commands.setContent(html);
// Clear undo/redo history so it doesn't bleed across notes // Clear undo/redo history so it doesn't bleed across notes
clearEditorHistory(); clearEditorHistory();
// Clear loading flag after setContent updates have fired // Reset scroll and cursor after all ProseMirror/Svelte DOM updates settle
tick().then(() => { isLoadingNote = false; }); tick().then(() => {
if (editorBody) editorBody.scrollTop = 0;
// Explicitly reset ProseMirror selection to start so TipTap's focus()
// (triggered by checkbox clicks etc.) doesn't scroll to the old note's cursor position.
if (editor) {
const tr = editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc));
// No tr.scrollIntoView() — must not trigger any scroll
editor.view.dispatch(tr);
}
requestAnimationFrame(() => { if (editorBody) editorBody.scrollTop = 0; });
isLoadingNote = false;
});
} else { } else {
// Editor element not in DOM yet (first note load). // Editor element not in DOM yet (first note load).
// Store content and let the $effect on editorElement handle init. // Store content and let the $effect on editorElement handle init.
@@ -1571,6 +1675,8 @@
return serializeListItem(node); return serializeListItem(node);
case 'horizontalRule': case 'horizontalRule':
return '---\n'; return '---\n';
case 'pageBreak':
return '<div style="page-break-after: always;"></div>\n';
case 'table': { case 'table': {
// Preserve tables as raw HTML // Preserve tables as raw HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
@@ -2291,12 +2397,62 @@
PdfEmbed, PdfEmbed,
MathBlock, MathBlock,
MathInline, MathInline,
Details.configure({ persist: false, HTMLAttributes: { class: 'editor-details' } }), PageBreak,
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
DetailsSummary, DetailsSummary,
DetailsContent, DetailsContent,
Extension.create({
name: 'collapsibleKeymap',
addProseMirrorPlugins() {
return [new Plugin({
key: new PluginKey('collapsibleKeymap'),
props: {
handleDOMEvents: {
keydown(view, event) {
const isTab = event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
const isEnter = event.key === 'Enter' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
if (!isTab && !isEnter) return false;
const { schema, selection } = view.state;
const from = selection.$from;
let summaryDepth = -1;
for (let d = from.depth; d >= 0; d--) {
if (from.node(d).type === schema.nodes.detailsSummary) { summaryDepth = d; break; }
}
if (summaryDepth === -1) return false;
event.preventDefault();
const detailsDepth = summaryDepth - 1;
const detailsNode = from.node(detailsDepth);
let detailsContentPos: number | null = null;
let pos = from.start(detailsDepth);
for (let i = 0; i < detailsNode.childCount; i++) {
const child = detailsNode.child(i);
if (child.type === schema.nodes.detailsContent) { detailsContentPos = pos + 1; break; }
pos += child.nodeSize;
}
if (detailsContentPos === null) return true;
// Open the section if it is closed
const domPos = view.domAtPos(from.pos);
let domNode = domPos.node as HTMLElement;
if (domNode.nodeType === 3) domNode = domNode.parentElement as HTMLElement;
const detailsEl = domNode?.closest('[data-type="details"]') as HTMLElement | null;
if (detailsEl) openDetailsEl(detailsEl);
// Sync open state into document + move cursor (single transaction)
const detailsPos = from.before(detailsDepth);
view.dispatch(view.state.tr
.setNodeMarkup(detailsPos, undefined, { open: true })
.setSelection(TextSelection.create(view.state.doc, detailsContentPos))
);
return true;
}
}
}
})];
}
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
SlashCommands, SlashCommands,
MoveLineShortcuts, MoveLineShortcuts,
TabIndent,
NoteSearchExtension, NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []), ...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
], ],
@@ -2304,9 +2460,11 @@
editorProps: { editorProps: {
attributes: { class: 'editor-content' }, attributes: { class: 'editor-content' },
handleDOMEvents: { handleDOMEvents: {
// Prevent details toggle button from stealing focus, which causes scroll-to-top. // Prevent focus-caused scroll jumps when clicking details toggle buttons.
// Also pre-focus the editor with preventScroll so TipTap's focus command // Pre-focusing with preventScroll means TipTap's focus() call sees
// sees hasFocus()=true and skips its scrolling view.focus() call. // hasFocus()=true and skips its scrolling view.focus() call.
// For task checkboxes: lock scroll on mousedown (before any dispatch fires)
// so that any synchronous or async scroll caused by the toggle is reverted.
mousedown: (view, event) => { mousedown: (view, event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('[data-type="details"] > button')) { if (target.closest('[data-type="details"] > button')) {
@@ -2315,6 +2473,16 @@
(view.dom as HTMLElement).focus({ preventScroll: true }); (view.dom as HTMLElement).focus({ preventScroll: true });
} }
} }
if (target.closest('[data-type="taskItem"] input[type="checkbox"]')) {
const editorBody = target.closest('.editor-body') as HTMLElement | null;
if (editorBody) {
const savedScroll = editorBody.scrollTop;
const restore = () => { editorBody!.scrollTop = savedScroll; };
editorBody.addEventListener('scroll', restore);
// Remove after 200ms — covers synchronous, rAF, and setTimeout-based scrolls
setTimeout(() => editorBody!.removeEventListener('scroll', restore), 200);
}
}
}, },
// Prevent native text drag — it causes copy-instead-of-move in Tauri's webview. // Prevent native text drag — it causes copy-instead-of-move in Tauri's webview.
// File drops from OS are handled by Tauri's onDragDropEvent listener instead. // File drops from OS are handled by Tauri's onDragDropEvent listener instead.
@@ -2756,8 +2924,31 @@
closeTextContextMenu(); closeTextContextMenu();
} }
function openDetailsEl(el: HTMLElement) {
if (!el.classList.contains('is-open')) {
el.classList.add('is-open');
(el.querySelector('[data-type="detailsContent"]') as HTMLElement | null)
?.dispatchEvent(new Event('toggleDetailsContent'));
}
}
function insertDetails() {
if (!editor) return;
editor.chain().focus().setDetails().run();
requestAnimationFrame(() => {
if (!editor) return;
const domPos = editor.view.domAtPos(editor.state.selection.from);
let node = domPos.node as HTMLElement;
if (node.nodeType === 3) node = node.parentElement as HTMLElement;
const detailsEl = node.closest('[data-type="details"]') as HTMLElement | null;
if (detailsEl) openDetailsEl(detailsEl);
// Sync open: true into the document so it saves with the note
editor.chain().updateAttributes('details', { open: true }).run();
});
}
function ctxDetails() { function ctxDetails() {
editor?.chain().focus().setDetails().run(); insertDetails();
closeTextContextMenu(); closeTextContextMenu();
} }
@@ -3429,6 +3620,16 @@
</div> </div>
{#if !isMobile} {#if !isMobile}
<div class="toolbar-actions"> <div class="toolbar-actions">
{#if $canGoBack || $canGoForward}
<div class="nav-history-btns">
<button class="nav-history-btn" disabled={!$canGoBack} onclick={() => editorNavigateHistory(-1)} title="Back (Alt+←)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="nav-history-btn" disabled={!$canGoForward} onclick={() => editorNavigateHistory(1)} title="Forward (Alt+→)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
{/if}
{#if $editorDirty} {#if $editorDirty}
<span class="save-indicator">Unsaved</span> <span class="save-indicator">Unsaved</span>
{/if} {/if}
@@ -3545,6 +3746,31 @@
{/if} {/if}
</div> </div>
{#if !$focusMode}
<div class="note-meta-bar">
<span class="note-folder" class:unfiled={!noteFolder}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{#if noteFolder}
{#each noteFolder.split('/') as segment, i}
{#if i > 0}<span class="path-sep"></span>{/if}{segment}
{/each}
{:else}
Unfiled Notes
{/if}
</span>
{#if $activeNote.meta.tags?.length > 0}
<span class="meta-divider">·</span>
{/if}
{#if $activeNote.meta.tags?.length > 0}
<span class="note-tags">
{#each $activeNote.meta.tags as tag}
<span class="note-tag">#{tag}</span>
{/each}
</span>
{/if}
</div>
{/if}
<div class="editor-body-wrapper"> <div class="editor-body-wrapper">
{#if noteSearchOpen} {#if noteSearchOpen}
<div class="note-search-bar"> <div class="note-search-bar">
@@ -3970,7 +4196,7 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
Quote Quote
</button> </button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setDetails().run(); }}> <button onclick={() => { insertDropdown = false; insertDetails(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
Collapsible Section Collapsible Section
</button> </button>
@@ -4079,7 +4305,7 @@
</button> </button>
<!-- Collapsible Section --> <!-- Collapsible Section -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => editor?.chain().focus().setDetails().run()} title="Collapsible Section"> <button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title="Collapsible Section">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
</button> </button>
@@ -4618,8 +4844,8 @@
<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-col"> <span class="wiki-link-title-col">
<span class="wiki-link-title">{entry.title}</span> <span class="wiki-link-title">{entry.title}</span>
{#if wikiLinkDuplicateTitles.has(entry.title.toLowerCase())} {#if wikiLinkFolderPath(entry)}
<span class="wiki-link-folder">{wikiLinkFolderPath(entry) || '(vault root)'}</span> <span class="wiki-link-folder">{wikiLinkFolderPath(entry)}</span>
{/if} {/if}
</span> </span>
</button> </button>
@@ -4908,6 +5134,40 @@
flex-shrink: 0; flex-shrink: 0;
} }
.nav-history-btns {
display: flex;
align-items: center;
gap: 2px;
margin-right: auto;
flex-shrink: 0;
}
.nav-history-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-tertiary);
border-radius: 5px;
cursor: pointer;
padding: 0;
transition: color 0.15s, background 0.15s;
}
.nav-history-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.nav-history-btn:disabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.editor-title { .editor-title {
flex: 1; flex: 1;
} }
@@ -4929,6 +5189,57 @@
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.note-meta-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
padding: 0 20px 10px;
flex-shrink: 0;
}
.note-folder {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-tertiary);
letter-spacing: 0.01em;
}
.note-folder.unfiled {
opacity: 0.6;
font-style: italic;
}
.path-sep {
font-size: 10px;
opacity: 0.5;
}
.meta-divider {
font-size: 11px;
color: var(--text-tertiary);
opacity: 0.4;
user-select: none;
}
.note-tags {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.note-tag {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 1px 7px;
border-radius: 10px;
letter-spacing: 0.01em;
}
.toolbar-actions { .toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5198,11 +5509,25 @@
.editor-body { .editor-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-anchor: none;
padding: 8px 20px; padding: 8px 20px;
min-width: 0; min-width: 0;
position: relative; position: relative;
} }
.editor-body::-webkit-scrollbar {
width: 8px;
}
.editor-body::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.editor-body::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.editor-body:has(.source-editor) { .editor-body:has(.source-editor) {
overflow: hidden; overflow: hidden;
} }
@@ -5738,6 +6063,12 @@
transform: rotate(90deg); transform: rotate(90deg);
} }
@starting-style {
:global(.tiptap-wrapper .tiptap [data-type="details"].is-open > button::after) {
transform: rotate(0deg);
}
}
:global(.tiptap-wrapper .tiptap [data-type="details"] summary) { :global(.tiptap-wrapper .tiptap [data-type="details"] summary) {
padding: 10px 14px 10px 32px; padding: 10px 14px 10px 32px;
font-weight: 600; font-weight: 600;
@@ -5864,7 +6195,10 @@
min-width: 0; min-width: 0;
} }
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] > div) { /* Strike through only the direct paragraph content of a checked task item.
Using > p instead of > div prevents text-decoration from bleeding into
nested task lists, since CSS text-decoration cannot be cancelled by descendants. */
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p) {
text-decoration: line-through; text-decoration: line-through;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
@@ -6111,10 +6445,34 @@
padding-left: 2px; padding-left: 2px;
} }
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] .is-empty::before) { /* Suppress placeholder on task list / details containers — overlaps with checkbox / toggle button */
:global(.tiptap-wrapper .tiptap > ul[data-type="taskList"].is-empty::before),
:global(.tiptap-wrapper .tiptap > [data-type="details"].is-empty::before) {
content: none; content: none;
} }
/* Show placeholder on the paragraph inside task item content div */
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li > div > p.is-empty::before) {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
float: left;
height: 0;
padding-left: 2px;
}
/* Show placeholder on paragraphs inside collapsible section summary and content */
:global(.tiptap-wrapper .tiptap [data-type="detailsSummary"] p.is-empty::before),
:global(.tiptap-wrapper .tiptap [data-type="detailsContent"] p.is-empty::before) {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
float: left;
height: 0;
padding-left: 2px;
}
.link-context-overlay { .link-context-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -6575,6 +6933,36 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
margin: 0; margin: 0;
} }
:global(.tiptap .page-break) {
position: relative;
margin: 12px 0;
height: 20px;
border: none;
pointer-events: none;
user-select: none;
}
:global(.tiptap .page-break::before) {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 0;
border-top: 2px dashed var(--text-tertiary, #aaa);
opacity: 0.5;
}
:global(.tiptap .page-break::after) {
content: 'Page Break';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-primary, #fff);
padding: 0 8px;
font-size: 11px;
color: var(--text-tertiary, #aaa);
white-space: nowrap;
}
:global(.tiptap .pdf-embed-mobile) { :global(.tiptap .pdf-embed-mobile) {
margin: 8px 0; margin: 8px 0;
} }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app'; import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api'; import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
+25 -1
View File
@@ -38,10 +38,11 @@
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
@@ -230,6 +231,15 @@
if (listContainer) listContainer.scrollTop = 0; if (listContainer) listContainer.scrollTop = 0;
}); });
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
noteCache.delete('quickaccess');
if ($viewMode === 'quickaccess') {
refresh();
}
});
// Track container height via ResizeObserver // Track container height via ResizeObserver
$effect(() => { $effect(() => {
if (!listContainer) return; if (!listContainer) return;
@@ -369,6 +379,7 @@
noteCache.clear(); noteCache.clear();
await refresh(); await refresh();
await selectNote(entry); await selectNote(entry);
onNoteCreated();
} catch (e) { } catch (e) {
console.error('Failed to create note:', e); console.error('Failed to create note:', e);
} }
@@ -1412,6 +1423,19 @@
padding: 4px; padding: 4px;
} }
.list-content::-webkit-scrollbar {
width: 8px;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.note-item { .note-item {
display: block; display: block;
width: 100%; width: 100%;
+3 -2
View File
@@ -67,11 +67,12 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') { if (mod && !e.shiftKey && e.code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
+1 -1
View File
@@ -4,7 +4,7 @@
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
+2 -1
View File
@@ -50,6 +50,7 @@
} }
} }
export async function refresh() { export async function refresh() {
try { try {
if (isMobile) { if (isMobile) {
@@ -490,7 +491,7 @@
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''} class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled} onclick={selectUnfiled}
> >
<span class="chevron-spacer"></span> <span style="width:13px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /> <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" /> <polyline points="14 2 14 8 20 8" />
+1
View File
@@ -221,6 +221,7 @@
color: var(--text-primary); color: var(--text-primary);
} }
.switch-vault-btn.active { .switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent); color: var(--accent);
+30 -1
View File
@@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import type { import type {
AppConfig, AppConfig,
NoteEntry, NoteEntry,
@@ -106,6 +106,35 @@ export async function checkForUpdateMobile() {
} }
} }
// Note navigation history
interface NavHistoryState { stack: string[]; index: number; skipping: boolean; }
function createNavHistory() {
const store = writable<NavHistoryState>({ stack: [], index: -1, skipping: false });
return {
subscribe: store.subscribe,
push(path: string) {
store.update(s => {
if (s.skipping) return { ...s, skipping: false };
const trimmed = s.stack.slice(0, s.index + 1);
return { stack: [...trimmed, path], index: trimmed.length, skipping: false };
});
},
go(direction: -1 | 1): string | null {
let target: string | null = null;
store.update(s => {
const newIdx = s.index + direction;
if (newIdx < 0 || newIdx >= s.stack.length) return s;
target = s.stack[newIdx];
return { ...s, index: newIdx, skipping: true };
});
return target;
},
};
}
export const navHistory = createNavHistory();
export const canGoBack = derived(navHistory, $h => $h.index > 0);
export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length - 1);
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode],
+1 -2
View File
@@ -2,8 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { openFile, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let { children } = $props(); let { children } = $props();