Compare commits

..
10 Commits
23 changed files with 1505 additions and 451 deletions
+8 -8
View File
@@ -1,7 +1,7 @@
# 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.4-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)
[![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.
No cloud, no lock-in.
## Download (v1.2.4)
## Download (v1.2.7)
### 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)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.4/HelixNotes_1.2.4_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.7/HelixNotes_1.2.7_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.4/HelixNotes_1.2.4_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
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.4/HelixNotes_1.2.4_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
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.4/HelixNotes_1.2.4_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
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.4/HelixNotes_1.2.4_android.apk)
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_android.apk)
---
@@ -55,7 +55,7 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
- Markdown editor with toolbar, slash commands, source mode, code highlighting
- `[[Wiki-links]]` and graph view
- Full-text search (Tantivy)
- Outline panel, daily notes, tags, drag-and-drop
- Outline panel, daily notes with calendar view, tags, drag-and-drop
- Math (KaTeX), PDF preview, Obsidian import
- AI writing tools (Ollama / Anthropic / OpenAI)
- Version history with diffs, automatic backups
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.2.5",
"version": "1.2.8",
"type": "module",
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.2.5"
version = "1.2.8"
dependencies = [
"arboard",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.2.5"
version = "1.2.8"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
+2
View File
@@ -12,6 +12,8 @@
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"dialog:default",
+95 -25
View File
@@ -112,6 +112,13 @@ pub fn get_notebooks(state: State<'_, AppState>) -> Result<Vec<NotebookEntry>, S
operations::scan_notebooks(vault_path)
}
#[tauri::command]
pub fn count_root_notes(state: State<'_, AppState>) -> Result<usize, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::count_root_notes(vault_path)
}
#[tauri::command]
pub fn create_notebook(
state: State<'_, AppState>,
@@ -250,6 +257,14 @@ pub fn save_note(
drop(config);
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(())
}
@@ -415,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 = 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 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 contents: Vec<String> = Vec::new();
@@ -447,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()
});
// 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();
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 {
title,
path: path_str,
@@ -461,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)
let mut edges = Vec::new();
let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
// edge_map: (src, tgt) → index in edges vec, used to detect reverse links
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() {
let bytes = body.as_bytes();
@@ -483,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.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) {
if target_idx != source_idx {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) };
if edge_set.insert(key) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx });
// 1. Try vault-relative path match (e.g. "arkhost/note name")
if let Some(&target_idx) = relpath_to_idx.get(&link) {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
} else if let Some(targets) = title_to_idxs.get(&link) {
// 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);
}
}
}
}
@@ -1355,34 +1405,54 @@ fn resolve_wiki_ref(
reference.to_string()
}
// ── Open file with system default handler ──
// ── Open file/URL with system default handler ──
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
fn xdg_open(arg: &str) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
let mut cmd = std::process::Command::new("xdg-open");
cmd.arg(arg);
// Clear AppImage environment so child processes find host binaries
// (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")]
{
std::process::Command::new("open")
.arg(&path)
.arg(arg)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", &path])
.args(["/C", "start", "", arg])
.spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
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]
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))?;
+2
View File
@@ -98,6 +98,7 @@ pub fn run() {
commands::set_font_family,
commands::set_line_height,
commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook,
commands::rename_notebook,
commands::delete_notebook,
@@ -136,6 +137,7 @@ pub fn run() {
commands::get_vault_stats,
commands::import_obsidian,
commands::open_file,
commands::open_url,
commands::copy_file_to,
commands::create_backup,
commands::list_backups,
+32 -9
View File
@@ -9,9 +9,9 @@ use tantivy::collector::TopDocs;
use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")]
use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser;
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument};
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir;
pub struct SearchIndex {
@@ -152,14 +152,37 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(
&self.index,
vec![self.title_field, self.body_field, self.tags_field],
);
let fields = vec![self.title_field, self.body_field, self.tags_field];
let terms: Vec<String> = query_str
.split_whitespace()
.map(|t| t.to_lowercase())
.collect();
let query = query_parser
.parse_query(query_str)
.map_err(|e| e.to_string())?;
// For each search term, create prefix + fuzzy queries across all fields (OR),
// then AND all terms together
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
.search(&query, &TopDocs::with_limit(limit))
+1
View File
@@ -285,4 +285,5 @@ pub struct GraphNode {
pub struct GraphEdge {
pub source: usize,
pub target: usize,
pub bidirectional: bool,
}
+49 -7
View File
@@ -167,6 +167,22 @@ fn is_hidden(path: &Path) -> bool {
.unwrap_or(false)
}
pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
let root = Path::new(vault_path);
if !root.exists() {
return Err("Vault path does not exist".to_string());
}
let count = fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count();
Ok(count)
}
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path);
@@ -627,6 +643,15 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
let trash_name = format!("{}_{}", timestamp, dirname);
let dest = trash_dir.join(&trash_name);
// Save original relative path in a sidecar .meta file for restore
let relative = src
.strip_prefix(vault_path)
.unwrap_or(src)
.to_string_lossy()
.to_string();
let meta_path = trash_dir.join(format!("{}.meta", trash_name));
let _ = fs::write(&meta_path, &relative);
fs::rename(src, dest).map_err(|e| e.to_string())?;
Ok(())
}
@@ -967,18 +992,35 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
return Err("Trashed notebook does not exist".to_string());
}
// Strip timestamp prefix to get original notebook name
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
let original_name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
&dirname[18..]
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
&dirname[15..]
// Try to read original path from sidecar .meta file
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original
} else {
&dirname
// Fallback for notebooks trashed before .meta files existed:
// strip timestamp prefix to get notebook name, restore to root
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
&dirname[18..]
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
&dirname[15..]
} else {
&dirname
};
name.to_string()
};
let dest = Path::new(vault_path).join(original_name);
let dest = Path::new(vault_path).join(&relative);
// Recreate parent directories if needed
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up .meta file
let _ = fs::remove_file(&meta_path);
Ok(dest.to_string_lossy().to_string())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.2.5",
"version": "1.2.8",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+8
View File
@@ -47,6 +47,10 @@ export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks");
}
export async function countRootNotes(): Promise<number> {
return invoke("count_root_notes");
}
export async function createNotebook(
parentRelative: string | null,
name: string,
@@ -263,6 +267,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path });
}
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
export async function copyFileTo(
source: string,
destination: string,
+50 -34
View File
@@ -34,14 +34,16 @@
tags,
notes,
viewMode,
activeTag,
updateAvailable as globalUpdateAvailable,
settingsTab
settingsTab,
navHistory
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess } from '$lib/api';
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store';
@@ -58,9 +60,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number {
switch (freq) {
@@ -91,32 +91,19 @@
// Track note navigation in history stack
$effect(() => {
const path = $activeNotePath;
if (navigatingFromHistory) {
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
if (path) navHistory.push(path);
});
function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction;
if (newIndex < 0 || newIndex >= noteHistory.length) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
const path = navHistory.go(direction);
if (!path) return;
readNote(path).then((content) => {
editor?.flushSave();
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
editor?.loadNote(path, content.content);
}).catch(() => {
// Note may have been deleted, ignore
});
}).catch(() => {});
}
async function handleOpenFile(filePath: string) {
@@ -252,6 +239,7 @@
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
navigateHistory(-1);
@@ -262,19 +250,25 @@
navigateHistory(1);
return;
}
if (mod && !e.shiftKey && e.key === 'n') {
if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault();
createAndFocusNote();
return;
}
if (mod && e.shiftKey && e.key === 'N') {
if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
if (mod && e.shiftKey && e.key === 'F') {
if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault();
$showSearch = true;
return;
}
if (mod && !e.shiftKey && e.key === 'f') {
if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault();
if ($activeNotePath) {
editor?.openNoteSearch();
@@ -283,28 +277,50 @@
}
return;
}
if (mod && e.key === 'p') {
if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault();
$showCommandPalette = true;
return;
}
if (mod && e.key === 's') {
if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault();
editor?.forceSave();
return;
}
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) {
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && e.key === 'M') {
if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault();
$sourceMode = !$sourceMode;
}
if (mod && e.shiftKey && e.key === 'W') {
if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault();
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
}
if (mod && e.key === '\\') {
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') {
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
@@ -464,7 +480,7 @@
{#if $mobileView === 'sidebar'}
HelixNotes
{: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}
</span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -568,7 +584,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div>
<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">
<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" />
@@ -633,7 +649,7 @@
{/if}
<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>
<ResizeHandle onResize={handleNotelistResize} />
+467 -62
View File
@@ -29,16 +29,15 @@
import katex from 'katex';
import 'katex/dist/katex.min.css';
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 { DOMSerializer } from '@tiptap/pm/model';
import { convertFileSrc } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { readFile } from '@tauri-apps/plugin-fs';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
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 type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
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: '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: '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: '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 copyToast = $state<'copying' | 'done' | null>(null);
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);
const lowlight = createLowlight(common);
@@ -307,24 +308,23 @@
renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes.src || '';
const name = HTMLAttributes.name || 'file.pdf';
if (isMobile) {
// On mobile, render as a clickable link instead of iframe
const showInline = !isMobile && ($appConfig?.pdf_preview ?? false);
if (showInline) {
const vaultRoot = $appConfig?.active_vault ?? '';
const pdfHeight = $appConfig?.pdf_height ?? 600;
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`);
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed-mobile' }),
['a', { href: '#', 'data-open-file': absPath, class: 'pdf-link-mobile', onclick: 'return false;' },
['span', { class: 'pdf-icon-mobile' }, '\uD83D\uDCC4'],
['span', {}, name],
],
const displaySrc = convertFileSrc(absPath);
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed' }),
['iframe', { src: displaySrc, width: '100%', height: `${pdfHeight}px`, frameborder: '0' }],
['p', { class: 'pdf-label' }, name],
];
}
const vaultRoot = $appConfig?.active_vault ?? '';
const pdfHeight = $appConfig?.pdf_height ?? 600;
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`);
const displaySrc = convertFileSrc(absPath);
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed' }),
['iframe', { src: displaySrc, width: '100%', height: `${pdfHeight}px`, frameborder: '0' }],
['p', { class: 'pdf-label' }, name],
// Non-inline: render as a clickable link (mobile + desktop with setting off)
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed-mobile' }),
['a', { href: decodeURIComponent(src), class: 'pdf-link-mobile' },
['span', { class: 'pdf-icon-mobile' }, '\uD83D\uDCC4'],
['span', {}, name],
],
];
},
});
@@ -390,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);
function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) {
@@ -498,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({
name: 'codeBlockLanguageSelect',
addGlobalAttributes() {
@@ -598,6 +643,22 @@
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() {
const wasSlashTyped = slashTypedByUser;
slashTypedByUser = false;
@@ -640,9 +701,11 @@
// Keep menu within viewport (account for virtual keyboard on mobile)
if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
let y = coords.bottom + 4;
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 };
slashSelectedIndex = 0;
@@ -762,9 +825,22 @@
if (pipeIdx >= 0) q = q.slice(0, pipeIdx);
q = q.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
if (!q) return wikiLinkTitlesCache;
return wikiLinkTitlesCache.filter(entry =>
entry.title.toLowerCase().includes(q)
);
// Score: 0 = exact, 1 = starts-with, 2 = word-start, 3 = contains
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)
@@ -1048,9 +1124,11 @@
const coords = editor.view.coordsAtPos(from);
let x = coords.left;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
let y = coords.bottom + 4;
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 };
wikiLinkSelectedIndex = 0;
}
@@ -1378,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).
* Call BEFORE updating $activeNote/$activeNotePath stores when switching notes. */
export function flushSave() {
@@ -1405,9 +1495,11 @@
const shouldBeReadOnly = isNewNote ? false : ($appConfig?.default_view_mode ?? false);
$readOnly = shouldBeReadOnly;
if (editor) editor.setEditable(!shouldBeReadOnly);
const editorBody = editorElement?.closest('.editor-body') as HTMLElement | null;
if ($sourceMode) {
sourceContent = stripTitleH1(content);
resetSourceHistory(sourceContent);
if (editorBody) editorBody.scrollTop = 0;
isLoadingNote = false;
} else if (editorElement && editor) {
// Editor already exists, just swap content
@@ -1416,8 +1508,19 @@
editor.commands.setContent(html);
// Clear undo/redo history so it doesn't bleed across notes
clearEditorHistory();
// Clear loading flag after setContent updates have fired
tick().then(() => { isLoadingNote = false; });
// Reset scroll and cursor after all ProseMirror/Svelte DOM updates settle
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 {
// Editor element not in DOM yet (first note load).
// Store content and let the $effect on editorElement handle init.
@@ -1572,6 +1675,8 @@
return serializeListItem(node);
case 'horizontalRule':
return '---\n';
case 'pageBreak':
return '<div style="page-break-after: always;"></div>\n';
case 'table': {
// Preserve tables as raw HTML
const tempDiv = document.createElement('div');
@@ -2086,16 +2191,17 @@
return `[${text}](${url.replace(/ /g, '%20')})`;
});
// Pre-process: transform PDF embed divs — iframes on desktop, clickable links on mobile
// Pre-process: transform PDF embed divs — iframes when inline preview is on, clickable links otherwise
src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => {
const vaultRoot = $appConfig?.active_vault ?? '';
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(pdfSrc)}`);
if (isMobile) {
return `<div class="pdf-embed-mobile"><a href="#" data-open-file="${absPath}" class="pdf-link-mobile" onclick="return false;">\uD83D\uDCC4 ${name}</a></div>`;
const showInline = !isMobile && ($appConfig?.pdf_preview ?? false);
if (showInline) {
const pdfHeight = $appConfig?.pdf_height ?? 600;
const displaySrc = convertFileSrc(absPath);
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed"><iframe src="${displaySrc}" width="100%" height="${pdfHeight}px"></iframe><p class="pdf-label">${name}</p></div>`;
}
const pdfHeight = $appConfig?.pdf_height ?? 600;
const displaySrc = convertFileSrc(absPath);
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed"><iframe src="${displaySrc}" width="100%" height="${pdfHeight}px"></iframe><p class="pdf-label">${name}</p></div>`;
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed-mobile"><a href="${decodeURIComponent(pdfSrc)}" class="pdf-link-mobile">\uD83D\uDCC4 ${name}</a></div>`;
});
// Pre-process: render KaTeX math — only outside fenced code blocks
@@ -2291,12 +2397,62 @@
PdfEmbed,
MathBlock,
MathInline,
Details.configure({ persist: false, HTMLAttributes: { class: 'editor-details' } }),
PageBreak,
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
DetailsSummary,
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'] }),
SlashCommands,
MoveLineShortcuts,
TabIndent,
NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
],
@@ -2304,9 +2460,11 @@
editorProps: {
attributes: { class: 'editor-content' },
handleDOMEvents: {
// Prevent details toggle button from stealing focus, which causes scroll-to-top.
// Also pre-focus the editor with preventScroll so TipTap's focus command
// sees hasFocus()=true and skips its scrolling view.focus() call.
// Prevent focus-caused scroll jumps when clicking details toggle buttons.
// Pre-focusing with preventScroll means TipTap's focus() call sees
// 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) => {
const target = event.target as HTMLElement;
if (target.closest('[data-type="details"] > button')) {
@@ -2315,21 +2473,17 @@
(view.dom as HTMLElement).focus({ preventScroll: true });
}
}
},
// On mobile, handle clicks on PDF file links to open in external app
...(isMobile ? { click: (_view: any, event: MouseEvent) => {
const target = (event.target as HTMLElement).closest('[data-open-file]') as HTMLElement | null;
if (target) {
event.preventDefault();
const filePath = target.getAttribute('data-open-file');
if (filePath) {
const bridge = (window as any).Android;
if (bridge && typeof bridge.openFile === 'function') {
bridge.openFile(filePath);
}
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.
// File drops from OS are handled by Tauri's onDragDropEvent listener instead.
dragstart: (_view, event) => {
@@ -2770,8 +2924,31 @@
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() {
editor?.chain().focus().setDetails().run();
insertDetails();
closeTextContextMenu();
}
@@ -3443,6 +3620,16 @@
</div>
{#if !isMobile}
<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}
<span class="save-indicator">Unsaved</span>
{/if}
@@ -3559,6 +3746,31 @@
{/if}
</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">
{#if noteSearchOpen}
<div class="note-search-bar">
@@ -3792,7 +4004,7 @@
{/each}
</div>
{/if}
{#if historySelected && historyPreview !== null}
{#if historySelected}
<div class="history-actions">
<button class="history-restore-btn" onclick={restoreVersion}>
Restore this version
@@ -3984,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>
Quote
</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>
Collapsible Section
</button>
@@ -4093,7 +4305,7 @@
</button>
<!-- 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>
</button>
@@ -4632,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>
<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 wikiLinkFolderPath(entry)}
<span class="wiki-link-folder">{wikiLinkFolderPath(entry)}</span>
{/if}
</span>
</button>
@@ -4922,6 +5134,40 @@
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 {
flex: 1;
}
@@ -4943,6 +5189,57 @@
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 {
display: flex;
align-items: center;
@@ -5212,11 +5509,25 @@
.editor-body {
flex: 1;
overflow-y: auto;
overflow-anchor: none;
padding: 8px 20px;
min-width: 0;
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) {
overflow: hidden;
}
@@ -5752,6 +6063,12 @@
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) {
padding: 10px 14px 10px 32px;
font-weight: 600;
@@ -5878,7 +6195,10 @@
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;
color: var(--text-tertiary);
}
@@ -6125,10 +6445,34 @@
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;
}
/* 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 {
position: fixed;
inset: 0;
@@ -6589,6 +6933,36 @@
border-top: 1px solid var(--border);
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) {
margin: 8px 0;
}
@@ -7194,14 +7568,45 @@
position: relative;
}
.editor-container.mobile .editor-body-row:has(.history-panel) > .editor-body,
.editor-container.mobile .editor-body-row:has(.outline-panel) > .editor-body {
display: none;
}
.editor-container.mobile .history-panel,
.editor-container.mobile .outline-panel {
position: absolute;
inset: 0;
position: static;
flex: 1;
min-height: 0;
width: 100% !important;
max-width: 100%;
border-left: none;
z-index: 10;
}
.editor-container.mobile .history-item {
padding: 12px 12px;
min-height: 48px;
}
.editor-container.mobile .history-list {
padding-bottom: 80px;
}
.editor-container.mobile .history-restore-btn {
padding: 14px 16px;
font-size: 15px;
}
.editor-container.mobile .history-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: var(--bg-secondary);
border-top: 1px solid var(--border-light);
z-index: 51;
}
.editor-container.mobile .note-search-bar {
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
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 type { VaultStats } from '$lib/types';
@@ -116,22 +116,37 @@
</div>
{:else}
<div class="shortcuts-section">
<h4 class="shortcuts-group-title">Keyboard Shortcuts</h4>
<h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Exit focus mode</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
+25 -1
View File
@@ -38,10 +38,11 @@
import { openNoteWindow } from '$lib/utils/window';
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;
onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
@@ -230,6 +231,15 @@
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
$effect(() => {
if (!listContainer) return;
@@ -369,6 +379,7 @@
noteCache.clear();
await refresh();
await selectNote(entry);
onNoteCreated();
} catch (e) {
console.error('Failed to create note:', e);
}
@@ -1412,6 +1423,19 @@
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 {
display: block;
width: 100%;
+3 -2
View File
@@ -67,11 +67,12 @@
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') {
if (mod && !e.shiftKey && e.code === 'KeyS') {
e.preventDefault();
editor?.forceSave();
return;
}
if (mod && e.shiftKey && e.key === 'M') {
if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault();
$sourceMode = !$sourceMode;
}
+1 -1
View File
@@ -4,7 +4,7 @@
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
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';
const isMobile = /android|ios/i.test(navigator.userAgent);
+37 -4
View File
@@ -15,9 +15,10 @@
notebookIcons,
appConfig,
quickAccessPaths,
collapsedNotebooks
collapsedNotebooks,
rootNoteCount
} from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core';
@@ -49,20 +50,25 @@
}
}
export async function refresh() {
try {
if (isMobile) {
// On mobile, parallelize and skip getAllTags (derive from $notes instead)
const [nbs, icons, qaNotes] = await Promise.all([
const [nbs, icons, qaNotes, rootCount] = await Promise.all([
getNotebooks(),
getNotebookIcons(),
getQuickAccess(),
countRootNotes(),
]);
$notebooks = nbs;
$notebookIcons = icons;
$quickAccessPaths = qaNotes.map(n => n.relative_path);
$rootNoteCount = rootCount;
} else {
$notebooks = await getNotebooks();
const [nbs, rootCount] = await Promise.all([getNotebooks(), countRootNotes()]);
$notebooks = nbs;
$rootNoteCount = rootCount;
$tags = await getAllTags();
$notebookIcons = await getNotebookIcons();
const qaNotes = await getQuickAccess();
@@ -80,6 +86,19 @@
onViewChanged();
}
function selectUnfiled() {
const vault = $appConfig?.active_vault;
if (!vault) return;
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
selectAllNotes();
return;
}
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
onViewChanged();
}
function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
@@ -466,6 +485,20 @@
{/if}
<div class="notebook-list">
{#if $rootNoteCount > 0}
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled}
>
<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">
<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" />
</svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button>
{/if}
{#each $notebooks as nb (nb.path)}
{@render notebookItem(nb, 0)}
{/each}
+1
View File
@@ -221,6 +221,7 @@
color: var(--text-primary);
}
.switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
+31 -1
View File
@@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store";
import { writable, derived, get } from "svelte/store";
import type {
AppConfig,
NoteEntry,
@@ -31,6 +31,7 @@ export const collapsedNotebooks = writable<string[]>([]);
// Data
export const notebooks = writable<NotebookEntry[]>([]);
export const rootNoteCount = writable<number>(0);
export const notes = writable<NoteEntry[]>([]);
export const tags = writable<[string, number][]>([]);
export const activeNote = writable<NoteContent | null>(null);
@@ -105,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
export const sortedNotes = derived(
[notes, sortMode, viewMode],
+40 -6
View File
@@ -2,8 +2,7 @@
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, readNote, getInstallType } from '$lib/api';
import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store';
let { children } = $props();
@@ -33,6 +32,19 @@
return resolved.join('/');
}
const isAndroid = /android/i.test(navigator.userAgent);
function openLocalFile(path: string) {
if (isAndroid) {
const bridge = (window as any).Android;
if (bridge && typeof bridge.openFile === 'function') {
bridge.openFile(path);
}
} else {
openFile(path).catch((err) => console.error('Failed to open file:', err));
}
}
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
@@ -62,7 +74,7 @@
activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err));
} else {
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
openLocalFile(absPath);
}
}
}
@@ -81,7 +93,17 @@
// Intercept all link clicks in capture phase to prevent webview navigation
onMount(() => {
function handleLinkClick(e: MouseEvent) {
const target = (e.target as HTMLElement)?.closest('a');
const el = e.target as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;
@@ -102,10 +124,22 @@
touchStartY = e.touches[0]?.clientY ?? 0;
}
function handleTouchEnd(e: TouchEvent) {
const endY = e.changedTouches[0]?.clientY ?? 0;
const touch = e.changedTouches[0];
if (!touch) return;
const endY = touch.clientY;
// If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return;
const target = (e.target as HTMLElement)?.closest('a');
// Android WebView may report e.target as the editor root for elements
// inside contenteditable="false" blocks; use elementFromPoint instead
const el = (document.elementFromPoint(touch.clientX, touch.clientY) ?? e.target) as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;