Compare commits

...
4 Commits
62 changed files with 2205 additions and 119 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.7-green)](https://codeberg.org/ArkHost/HelixNotes/releases) [![Latest Release](https://img.shields.io/badge/release-v1.2.8-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.7) ## Download (v1.2.8)
### 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.7/HelixNotes_1.2.7_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.8/HelixNotes_1.2.8_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_amd64.deb) (Ubuntu 22.04+) [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64-setup.exe) (Windows 10/11) [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[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) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android ### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_android.apk) [Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_android.apk)
--- ---
+3 -2
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.8", "version": "1.2.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -65,6 +65,7 @@
"markdown-it-mark": "^4.0.0", "markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0", "markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0", "markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1" "markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.14.0"
} }
} }
+937
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.8" version = "1.2.9"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.8" version = "1.2.9"
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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 B

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

-1
View File
@@ -190,7 +190,6 @@ async fn stream_anthropic(
} }
} }
// Stream ended — send done
let _ = app.emit( let _ = app.emit(
"ai-stream", "ai-stream",
AiStreamEvent { AiStreamEvent {
+1 -1
View File
@@ -207,7 +207,7 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
// Keep hidden dirs (.helixnotes) we'll handle attachments separately // Keep hidden dirs (.helixnotes) - we'll handle attachments separately
if name.starts_with('.') { if name.starts_with('.') {
continue; continue;
} }
+39 -6
View File
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Manager, State};
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
operations::ensure_vault_structure(&path)?; operations::ensure_vault_structure(&path)?;
// Initialize search index rebuild in background on Android (FUSE is slow) // Initialize search index - rebuild in background on Android (FUSE is slow)
let search = std::sync::Arc::new(SearchIndex::new(&path)?); let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
@@ -430,7 +430,7 @@ 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 as nodes (no title deduplication every note must appear) // 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();
// title → list of node indices (one title can map to multiple notes in different folders) // 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(); let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
@@ -495,7 +495,7 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
if src == tgt { return; } if src == tgt { return; }
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) { if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
// Reverse direction already exists mark it as bidirectional // Reverse direction already exists - mark it as bidirectional
edges[rev_idx].bidirectional = true; edges[rev_idx].bidirectional = true;
} else { } else {
let idx = edges.len(); let idx = edges.len();
@@ -527,7 +527,7 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
if let Some(&target_idx) = relpath_to_idx.get(&link) { if let Some(&target_idx) = relpath_to_idx.get(&link) {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx); add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
} else if let Some(targets) = title_to_idxs.get(&link) { } else if let Some(targets) = title_to_idxs.get(&link) {
// 2. Title match connect to all notes with this title // 2. Title match - connect to all notes with this title
for &target_idx in targets { for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx); add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
} }
@@ -728,6 +728,32 @@ pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string()) Err("Clipboard image copy not supported on Android".to_string())
} }
/// Copy PNG bytes directly to the system clipboard.
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
let img = image::load_from_memory(&data)
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let img_data = arboard::ImageData {
width: w as usize,
height: h as usize,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
};
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard.set_image(img_data)
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
Ok(())
}
#[cfg(target_os = "android")]
#[tauri::command]
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
// ── Attachments ── // ── Attachments ──
#[tauri::command] #[tauri::command]
@@ -1459,6 +1485,13 @@ pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command]
pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String> {
std::fs::write(&destination, &data).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(())
}
// ── Backup ── // ── Backup ──
#[tauri::command] #[tauri::command]
@@ -1717,10 +1750,10 @@ pub fn ai_ask(
let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \ let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \
You help users improve, rewrite, summarize, and transform their text. \ You help users improve, rewrite, summarize, and transform their text. \
Return ONLY the resulting text no explanations, no markdown code fences, no preamble. \ Return ONLY the resulting text - no explanations, no markdown code fences, no preamble. \
Preserve the original language of the text unless specifically asked to translate. \ Preserve the original language of the text unless specifically asked to translate. \
Preserve any markdown formatting (bold, italic, links, images, tables, etc.) unless the user asks to change it. \ Preserve any markdown formatting (bold, italic, links, images, tables, etc.) unless the user asks to change it. \
If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions they represent images and embedded files.".to_string(); If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions - they represent images and embedded files.".to_string();
if let Some(ref style) = writing_style { if let Some(ref style) = writing_style {
system_prompt.push_str(&format!( system_prompt.push_str(&format!(
+1 -1
View File
@@ -157,7 +157,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
.filter(|p| p.extension().map_or(false, |ext| ext == "md")) .filter(|p| p.extension().map_or(false, |ext| ext == "md"))
.collect(); .collect();
// Sort by name (timestamps sort lexicographically) newest last // Sort by name (timestamps sort lexicographically) - newest last
files.sort(); files.sort();
if files.len() as u32 > max { if files.len() as u32 > max {
+2
View File
@@ -139,6 +139,8 @@ pub fn run() {
commands::open_file, commands::open_file,
commands::open_url, commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::write_bytes_to,
commands::copy_png_to_clipboard,
commands::create_backup, commands::create_backup,
commands::list_backups, commands::list_backups,
commands::restore_backup, commands::restore_backup,
+6
View File
@@ -199,6 +199,10 @@ pub struct VaultState {
pub sidebar_collapsed: bool, pub sidebar_collapsed: bool,
#[serde(default)] #[serde(default)]
pub collapsed_notebooks: Vec<String>, pub collapsed_notebooks: Vec<String>,
#[serde(default)]
pub notebook_sort_mode: String,
#[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>,
} }
impl Default for VaultState { impl Default for VaultState {
@@ -209,6 +213,8 @@ impl Default for VaultState {
notelist_width: 280.0, notelist_width: 280.0,
sidebar_collapsed: false, sidebar_collapsed: false,
collapsed_notebooks: Vec::new(), collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(),
} }
} }
} }
+2 -2
View File
@@ -61,7 +61,7 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
.and_then(|s| parse_date_flexible(&s)) .and_then(|s| parse_date_flexible(&s))
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
// Don't generate UUID on read empty string signals "no ID yet" // Don't generate UUID on read - empty string signals "no ID yet"
let id = fm.id.unwrap_or_default(); let id = fm.id.unwrap_or_default();
let meta = NoteMeta { let meta = NoteMeta {
@@ -309,7 +309,7 @@ fn strip_html_and_markdown(input: &str) -> String {
continue; continue;
} }
// Markdown links: [text](url) keep the text, skip the URL // Markdown links: [text](url) - keep the text, skip the URL
if ch == '[' { if ch == '[' {
let mut link_text = String::new(); let mut link_text = String::new();
let mut depth = 1; let mut depth = 1;
+1 -2
View File
@@ -281,7 +281,7 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
/// uses filesystem timestamps for dates. No preview text. /// uses filesystem timestamps for dates. No preview text.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> { fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB enough for frontmatter with tags, title, pinned // Read first 2KB - enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?; let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; 2048]; let mut buf = vec![0u8; 2048];
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?; let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
@@ -917,7 +917,6 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
notes.push(note); notes.push(note);
} }
} else if path.is_dir() { } else if path.is_dir() {
// Deleted notebook — count .md files inside
let note_count = WalkDir::new(&path) let note_count = WalkDir::new(&path)
.min_depth(1) .min_depth(1)
.into_iter() .into_iter()
+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.8", "version": "1.2.9",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+11
View File
@@ -278,6 +278,17 @@ export async function copyFileTo(
return invoke("copy_file_to", { source, destination }); return invoke("copy_file_to", { source, destination });
} }
export async function writeBytesTo(
destination: string,
data: Uint8Array,
): Promise<void> {
return invoke("write_bytes_to", { destination, data: Array.from(data) });
}
export async function copyPngToClipboard(data: Uint8Array): Promise<void> {
return invoke("copy_png_to_clipboard", { data: Array.from(data) });
}
// ── Backup ── // ── Backup ──
export async function createBackup(): Promise<void> { export async function createBackup(): Promise<void> {
+43 -5
View File
@@ -37,12 +37,16 @@
activeTag, activeTag,
updateAvailable as globalUpdateAvailable, updateAvailable as globalUpdateAvailable,
settingsTab, settingsTab,
navHistory navHistory,
viewerNote,
notebookSortMode,
notebookOrder
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
@@ -110,10 +114,32 @@
if (!filePath || !filePath.endsWith('.md')) return; if (!filePath || !filePath.endsWith('.md')) return;
const config = get(appConfig); const config = get(appConfig);
const vaultRoot = config?.active_vault; const vaultRoot = config?.active_vault;
if (!vaultRoot || !filePath.startsWith(vaultRoot)) return; const isExternal = !vaultRoot || !filePath.startsWith(vaultRoot + '/');
// External .md → viewer mode (skipped on Android)
if (isExternal) {
if (isAndroid) return;
try {
const content = await readNote(filePath);
editor?.flushSave();
$viewerNote = { path: filePath, content: content.content };
$activeNote = content;
$activeNotePath = filePath;
$editorDirty = false;
$readOnly = true;
$focusMode = true;
editor?.loadNote(filePath, content.content);
} catch (e) {
console.error('Failed to open external file:', e);
}
return;
}
// Vault file: normal flow
try { try {
const content = await readNote(filePath); const content = await readNote(filePath);
editor?.flushSave(); editor?.flushSave();
$viewerNote = null;
$activeNote = content; $activeNote = content;
$activeNotePath = filePath; $activeNotePath = filePath;
$editorDirty = false; $editorDirty = false;
@@ -129,7 +155,9 @@
sidebar_width: $sidebarWidth, sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
collapsed_notebooks: $collapsedNotebooks collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder
}; };
try { try {
await saveVaultState(state); await saveVaultState(state);
@@ -147,6 +175,8 @@
} }
function handleNoteSelected(path: string, content: string) { function handleNoteSelected(path: string, content: string) {
// Selecting a real vault note exits viewer mode
$viewerNote = null;
editor?.loadNote(path, content); editor?.loadNote(path, content);
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} }
@@ -197,14 +227,13 @@
} }
const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2; const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2;
if (targetDepth > historyDepth) { if (targetDepth > historyDepth) {
// Forward navigation push entries for each level skipped // Forward navigation - push entries for each level skipped
for (let d = historyDepth + 1; d <= targetDepth; d++) { for (let d = historyDepth + 1; d <= targetDepth; d++) {
const v = d === 1 ? 'notelist' : 'editor'; const v = d === 1 ? 'notelist' : 'editor';
history.pushState({ mobileView: v, depth: d }, ''); history.pushState({ mobileView: v, depth: d }, '');
} }
historyDepth = targetDepth; historyDepth = targetDepth;
} else if (targetDepth < historyDepth) { } else if (targetDepth < historyDepth) {
// Programmatic back (e.g. mobileBack button) — go back in history
const steps = historyDepth - targetDepth; const steps = historyDepth - targetDepth;
historyDepth = targetDepth; historyDepth = targetDepth;
navFromPopstate = true; // suppress the popstate that history.go triggers navFromPopstate = true; // suppress the popstate that history.go triggers
@@ -313,6 +342,7 @@
} }
if (mod && e.shiftKey && code === 'KeyR') { if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault(); e.preventDefault();
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly; $readOnly = !$readOnly;
return; return;
} }
@@ -352,6 +382,12 @@
persistState(); persistState();
}); });
$effect(() => {
$notebookSortMode;
$notebookOrder;
persistState();
});
onMount(async () => { onMount(async () => {
let lastNotePath: string | null = null; let lastNotePath: string | null = null;
try { try {
@@ -360,6 +396,8 @@
$notelistWidth = state.notelist_width; $notelistWidth = state.notelist_width;
$sidebarCollapsed = state.sidebar_collapsed; $sidebarCollapsed = state.sidebar_collapsed;
$collapsedNotebooks = state.collapsed_notebooks ?? []; $collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {};
lastNotePath = state.last_open_note ?? null; lastNotePath = state.last_open_note ?? null;
} catch (_) {} } catch (_) {}
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -54,7 +54,7 @@
let canvasW = 0; let canvasW = 0;
let canvasH = 0; let canvasH = 0;
// Style cache cleared on theme change // Style cache - cleared on theme change
let cachedStyles: { border: string; text: string; textSec: string; accent: string; bg: string } | null = null; let cachedStyles: { border: string; text: string; textSec: string; accent: string; bg: string } | null = null;
const FOLDER_PALETTE = [ const FOLDER_PALETTE = [
@@ -231,14 +231,14 @@
} }
function startSimulation() { function startSimulation() {
// Initial settle enough for a rough layout, camera set after full physics // Initial settle - enough for a rough layout, camera set after full physics
for (let i = 0; i < 80; i++) simulate(); for (let i = 0; i < 80; i++) simulate();
// Show the full graph while physics finishes settling // Show the full graph while physics finishes settling
fitToView(); fitToView();
draw(); draw();
// Queue remaining physics camera will snap to active note when done // Queue remaining physics - camera will snap to active note when done
physicsRemaining = Math.min(400, Math.max(150, nodes.length * 3)); physicsRemaining = Math.min(400, Math.max(150, nodes.length * 3));
startAnimLoop(); startAnimLoop();
} }
@@ -262,7 +262,7 @@
needsDraw = true; needsDraw = true;
} }
// Glow pulse for active node throttled to ~30fps // Glow pulse for active node - throttled to ~30fps
if (activeNodeIdx >= 0) { if (activeNodeIdx >= 0) {
const elapsed = lastGlowTs > 0 ? Math.min(ts - lastGlowTs, 100) : 16.67; const elapsed = lastGlowTs > 0 ? Math.min(ts - lastGlowTs, 100) : 16.67;
if (lastGlowTs === 0 || ts - lastGlowTs >= 33) { if (lastGlowTs === 0 || ts - lastGlowTs >= 33) {
@@ -315,7 +315,7 @@
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
} }
// Center gravity weaker for isolated nodes so they spread instead of clustering // Center gravity - weaker for isolated nodes so they spread instead of clustering
for (let i = 0; i < nodeCount; i++) { for (let i = 0; i < nodeCount; i++) {
const node = nodes[i]; const node = nodes[i];
const gravity = (nodeDegree[i] || 0) === 0 ? 0.0002 : 0.0008; const gravity = (nodeDegree[i] || 0) === 0 ? 0.0002 : 0.0008;
@@ -418,7 +418,6 @@
ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y); ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
} }
ctx.stroke(); ctx.stroke();
// Edges connected to search matches — brighter
ctx.globalAlpha = 0.4; ctx.globalAlpha = 0.4;
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < edges.length; i++) { for (let i = 0; i < edges.length; i++) {
@@ -497,7 +496,7 @@
const radius = getNodeRadius(i, isActive); const radius = getNodeRadius(i, isActive);
// Active glow ring opacity pulse, no radius change // Active glow ring - opacity pulse, no radius change
if (isActive) { if (isActive) {
const glowR = radius + 12; const glowR = radius + 12;
const glow = ctx.createRadialGradient(node.x, node.y, radius, node.x, node.y, glowR); const glow = ctx.createRadialGradient(node.x, node.y, radius, node.x, node.y, glowR);
@@ -524,7 +523,7 @@
} }
ctx.fill(); ctx.fill();
// Active outer ring static, alpha pulse only // Active outer ring - static, alpha pulse only
if (isActive) { if (isActive) {
ctx.globalAlpha = 0.35 + pulse * 0.3; ctx.globalAlpha = 0.35 + pulse * 0.3;
ctx.beginPath(); ctx.beginPath();
@@ -554,7 +553,7 @@
labelQueue.push({ x: node.x, y: node.y - radius - 5, text, fontSize, weight, color, alpha, priority }); labelQueue.push({ x: node.x, y: node.y - radius - 5, text, fontSize, weight, color, alpha, priority });
} }
// Draw labels with collision avoidance high-priority labels drawn first // Draw labels with collision avoidance - high-priority labels drawn first
labelQueue.sort((a, b) => a.priority - b.priority); labelQueue.sort((a, b) => a.priority - b.priority);
const drawnRects: LabelRect[] = []; const drawnRects: LabelRect[] = [];
ctx.textAlign = 'center'; ctx.textAlign = 'center';
+25 -2
View File
@@ -36,6 +36,7 @@
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
@@ -714,7 +715,7 @@
return; return;
} }
if (e.shiftKey) { if (e.shiftKey) {
// Range select use lastClickedPath or active note as anchor // Range select - use lastClickedPath or active note as anchor
const anchor = lastClickedPath || $activeNotePath; const anchor = lastClickedPath || $activeNotePath;
if (!anchor) { selectNote(note); return; } if (!anchor) { selectNote(note); return; }
const paths = $sortedNotes.map(n => n.path); const paths = $sortedNotes.map(n => n.path);
@@ -731,7 +732,6 @@
} }
return; return;
} }
// Normal click — clear selection, open note
clearSelection(); clearSelection();
selectNote(note); selectNote(note);
} }
@@ -1101,6 +1101,11 @@
<path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/> <path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
</svg> </svg>
{/if} {/if}
{#if $viewMode !== 'quickaccess' && $quickAccessPaths.includes(note.relative_path)}
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="quickaccess-icon">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
{/if}
{note.meta.title} {note.meta.title}
</span> </span>
{#if getNotebookPath(note)} {#if getNotebookPath(note)}
@@ -1115,6 +1120,11 @@
<path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/> <path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
</svg> </svg>
{/if} {/if}
{#if $viewMode !== 'quickaccess' && $quickAccessPaths.includes(note.relative_path)}
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="quickaccess-icon">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
{/if}
{note.meta.title} {note.meta.title}
</div> </div>
<div class="note-preview">{note.preview}</div> <div class="note-preview">{note.preview}</div>
@@ -1301,6 +1311,14 @@
</svg> </svg>
Open in New Window Open in New Window
</button> </button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
Show in File Manager
</button>
{/if}
<button onclick={() => startRename(contextMenu!.note)}> <button onclick={() => startRename(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/> <path d="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
@@ -1632,6 +1650,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
.quickaccess-icon {
color: var(--text-accent);
flex-shrink: 0;
}
.note-preview { .note-preview {
font-size: 0.85em; font-size: 0.85em;
color: var(--text-tertiary); color: var(--text-tertiary);
+16 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile } from '$lib/stores/app'; import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api'; import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
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';
@@ -599,6 +599,20 @@
</label> </label>
</div> </div>
<div class="settings-section">
<h3>Notebooks</h3>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Sort order</span>
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
</span>
</label>
<div class="setting-options" style="margin-top: 8px;">
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3>Time Format</h3> <h3>Time Format</h3>
<div class="setting-options"> <div class="setting-options">
@@ -717,6 +731,7 @@
</label> </label>
</div> </div>
{#if !isMobile} {#if !isMobile}
<div class="settings-section"> <div class="settings-section">
<h3>PDF Preview</h3> <h3>PDF Preview</h3>
+137 -7
View File
@@ -16,7 +16,9 @@
appConfig, appConfig,
quickAccessPaths, quickAccessPaths,
collapsedNotebooks, collapsedNotebooks,
rootNoteCount rootNoteCount,
notebookSortMode,
notebookOrder
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } 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 { open as openDialog } from '@tauri-apps/plugin-dialog';
@@ -36,7 +38,23 @@
let newNotebookName = $state(''); let newNotebookName = $state('');
let showNewNotebook = $state(false); let showNewNotebook = $state(false);
let dropTargetPath = $state<string | null>(null); let dropTargetPath = $state<string | null>(null);
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
let draggedNotebookPath = $state<string | null>(null); let draggedNotebookPath = $state<string | null>(null);
// Apply manual ordering recursively when in 'manual' sort mode.
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
function sortNotebooksTree(tree: NotebookEntry[]): NotebookEntry[] {
if ($notebookSortMode !== 'manual') return tree;
const order = $notebookOrder;
const sorted = [...tree].sort((a, b) => {
const oa = order[a.path] ?? Number.MAX_SAFE_INTEGER;
const ob = order[b.path] ?? Number.MAX_SAFE_INTEGER;
if (oa !== ob) return oa - ob;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
return sorted.map(nb => ({ ...nb, children: sortNotebooksTree(nb.children) }));
}
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null); let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null); let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true); let tagsCollapsed = $state(true);
@@ -265,6 +283,94 @@
} }
} }
function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] {
if (parentPath === vaultRoot) return tree;
for (const nb of tree) {
if (nb.path === parentPath) return nb.children;
if (nb.children.length > 0) {
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
if (found.length > 0) return found;
}
}
return [];
}
async function handleNotebookReorder(srcPath: string, targetPath: string, position: 'above' | 'below') {
dropTargetPath = null;
dropPosition = null;
draggedNotebookPath = null;
if (srcPath === targetPath) return;
if (targetPath.startsWith(srcPath + '/')) return; // can't move into descendant
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return;
const srcParent = srcPath.substring(0, srcPath.lastIndexOf('/'));
const targetParent = targetPath.substring(0, targetPath.lastIndexOf('/'));
let actualSrcPath = srcPath;
try {
// Step 1: if different parents, move src into target's parent first
if (srcParent !== targetParent) {
const oldName = srcPath.split('/').pop() || '';
const newPath = targetParent === vaultRoot
? vaultRoot + '/' + oldName
: targetParent + '/' + oldName;
await moveNotebook(srcPath, targetParent);
// Migrate paths in collapsedNotebooks
$collapsedNotebooks = $collapsedNotebooks.map(p => {
if (p === srcPath) return newPath;
if (p.startsWith(srcPath + '/')) return newPath + p.slice(srcPath.length);
return p;
});
// Migrate paths in notebookOrder
const migrated: Record<string, number> = {};
for (const [p, v] of Object.entries($notebookOrder)) {
if (p === srcPath) migrated[newPath] = v;
else if (p.startsWith(srcPath + '/')) migrated[newPath + p.slice(srcPath.length)] = v;
else migrated[p] = v;
}
$notebookOrder = migrated;
// Update active notebook/note
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
selectAllNotes();
}
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
$activeNotePath = newNotePath;
$activeNote = await readNote(newNotePath);
}
$notebookIcons = await getNotebookIcons();
await refresh();
actualSrcPath = newPath;
}
// Step 2: reorder within target's parent
const siblings = findSiblingsOfPath($notebooks, targetParent, vaultRoot);
const sortedSiblings = sortNotebooksTree(siblings);
const without = sortedSiblings.filter(s => s.path !== actualSrcPath);
const targetIdx = without.findIndex(s => s.path === targetPath);
if (targetIdx === -1) return;
const insertIdx = position === 'above' ? targetIdx : targetIdx + 1;
const newSequence = [
...without.slice(0, insertIdx).map(s => s.path),
actualSrcPath,
...without.slice(insertIdx).map(s => s.path),
];
// Assign sequential positions to all siblings (only within this parent)
const newOrder = { ...$notebookOrder };
newSequence.forEach((path, idx) => {
newOrder[path] = idx;
});
$notebookOrder = newOrder;
// Auto-enable manual mode (the act of reordering implies user wants this)
if ($notebookSortMode !== 'manual') $notebookSortMode = 'manual';
} catch (e) {
console.error('Failed to reorder notebook:', e);
}
}
function startRename(nb: NotebookEntry) { function startRename(nb: NotebookEntry) {
contextMenu = null; contextMenu = null;
editingNotebook = nb.path; editingNotebook = nb.path;
@@ -499,7 +605,7 @@
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span> <span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button> </button>
{/if} {/if}
{#each $notebooks as nb (nb.path)} {#each sortedNotebooks as nb (nb.path)}
{@render notebookItem(nb, 0)} {@render notebookItem(nb, 0)}
{/each} {/each}
</div> </div>
@@ -649,13 +755,15 @@
<button <button
class="notebook-item" class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path} class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
class:drop-target={dropTargetPath === nb.path} class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {8 + depth * 16}px" style="padding-left: {8 + depth * 16}px"
draggable="true" draggable="true"
onclick={() => selectNotebook(nb)} onclick={() => selectNotebook(nb)}
oncontextmenu={(e) => onContextMenu(e, nb)} oncontextmenu={(e) => onContextMenu(e, nb)}
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }} ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
ondragend={() => { draggedNotebookPath = null; }} ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
ondragover={(e) => { ondragover={(e) => {
e.preventDefault(); e.preventDefault();
if (draggedNotebookPath) { if (draggedNotebookPath) {
@@ -664,17 +772,33 @@
e.dataTransfer!.dropEffect = 'none'; e.dataTransfer!.dropEffect = 'none';
return; return;
} }
// Prevent no-op (already in this parent) }
// Decide drop position from cursor Y within the row: top 30% = above, bottom 30% = below, middle = into
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const relY = (e.clientY - rect.top) / rect.height;
let pos: 'above' | 'into' | 'below';
if (draggedNotebookPath && relY < 0.3) pos = 'above';
else if (draggedNotebookPath && relY > 0.7) pos = 'below';
else pos = 'into';
// For 'into', reject if already in that parent (no-op)
if (pos === 'into' && draggedNotebookPath) {
const parentDir = draggedNotebookPath.substring(0, draggedNotebookPath.lastIndexOf('/')); const parentDir = draggedNotebookPath.substring(0, draggedNotebookPath.lastIndexOf('/'));
if (parentDir === nb.path) { e.dataTransfer!.dropEffect = 'none'; return; } if (parentDir === nb.path) { e.dataTransfer!.dropEffect = 'none'; return; }
} }
e.dataTransfer!.dropEffect = 'move'; e.dataTransfer!.dropEffect = 'move';
dropTargetPath = nb.path; dropTargetPath = nb.path;
dropPosition = pos;
}} }}
ondragleave={() => { if (dropTargetPath === nb.path) dropTargetPath = null; }} ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
ondrop={(e) => { ondrop={(e) => {
e.preventDefault();
const pos = dropPosition;
if (draggedNotebookPath) { if (draggedNotebookPath) {
handleNotebookDrop(e, nb.path); if (pos === 'above' || pos === 'below') {
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
} else {
handleNotebookDrop(e, nb.path);
}
} else { } else {
handleNoteDrop(e, nb); handleNoteDrop(e, nb);
} }
@@ -909,6 +1033,12 @@
outline-offset: -2px; outline-offset: -2px;
border-radius: 6px; border-radius: 6px;
} }
.notebook-item.drop-above {
box-shadow: 0 -2px 0 0 var(--accent);
}
.notebook-item.drop-below {
box-shadow: 0 2px 0 0 var(--accent);
}
.chevron { .chevron {
display: flex; display: flex;
+1 -2
View File
@@ -31,7 +31,7 @@
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return; if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
// Don't start dragging near window edges let Tauri handle resize // Don't start dragging near window edges - let Tauri handle resize
if (!maximized) { if (!maximized) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return; if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return;
@@ -39,7 +39,6 @@
const now = Date.now(); const now = Date.now();
if (now - lastMouseDown < 300) { if (now - lastMouseDown < 300) {
// Double-click detected — maximize/restore
appWindow.toggleMaximize(); appWindow.toggleMaximize();
lastMouseDown = 0; lastMouseDown = 0;
return; return;
+1 -1
View File
@@ -154,7 +154,7 @@
<span class="vault-path-hint">{fullPath}</span> <span class="vault-path-hint">{fullPath}</span>
</div> </div>
{:else} {:else}
<p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder existing .md files will be recognized automatically.</p> <p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder - existing .md files will be recognized automatically.</p>
{/if} {/if}
{#if error} {#if error}
+21 -1
View File
@@ -29,6 +29,13 @@ export const notebookIcons = writable<Record<string, string>>({});
export const quickAccessPaths = writable<string[]>([]); export const quickAccessPaths = writable<string[]>([]);
export const collapsedNotebooks = writable<string[]>([]); export const collapsedNotebooks = writable<string[]>([]);
// Notebook sort: 'alphabetical' (default) or 'manual' (drag-to-reorder, persisted in notebookOrder).
export const notebookSortMode = writable<"alphabetical" | "manual">(
"alphabetical",
);
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
export const notebookOrder = writable<Record<string, number>>({});
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
export const rootNoteCount = writable<number>(0); export const rootNoteCount = writable<number>(0);
@@ -39,6 +46,13 @@ export const activeNotePath = writable<string | null>(null);
export const activeNotebook = writable<NotebookEntry | null>(null); export const activeNotebook = writable<NotebookEntry | null>(null);
export const activeTag = writable<string | null>(null); export const activeTag = writable<string | null>(null);
// External-file viewer mode: set when an .md file outside the active vault is opened.
// While set, the editor is forced read-only, autosave is suppressed, and a banner
// offering "Import to vault" / "Close" is shown.
export const viewerNote = writable<{ path: string; content: string } | null>(
null,
);
// Mobile state // Mobile state
export const mobileView = writable<"sidebar" | "notelist" | "editor">( export const mobileView = writable<"sidebar" | "notelist" | "editor">(
"sidebar", "sidebar",
@@ -71,7 +85,7 @@ export async function checkForUpdate() {
updateObj.set(update); updateObj.set(update);
} }
} catch { } catch {
// Silent fail don't disrupt app startup // Silent fail - don't disrupt app startup
} }
} }
@@ -174,6 +188,8 @@ export const vaultState = derived(
notelistWidth, notelistWidth,
sidebarCollapsed, sidebarCollapsed,
collapsedNotebooks, collapsedNotebooks,
notebookSortMode,
notebookOrder,
], ],
([ ([
$activeNotePath, $activeNotePath,
@@ -181,6 +197,8 @@ export const vaultState = derived(
$notelistWidth, $notelistWidth,
$sidebarCollapsed, $sidebarCollapsed,
$collapsedNotebooks, $collapsedNotebooks,
$notebookSortMode,
$notebookOrder,
]) => { ]) => {
return { return {
last_open_note: $activeNotePath, last_open_note: $activeNotePath,
@@ -188,6 +206,8 @@ export const vaultState = derived(
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
} satisfies VaultState; } satisfies VaultState;
}, },
); );
+2
View File
@@ -86,6 +86,8 @@ export interface VaultState {
notelist_width: number; notelist_width: number;
sidebar_collapsed: boolean; sidebar_collapsed: boolean;
collapsed_notebooks: string[]; collapsed_notebooks: string[];
notebook_sort_mode?: string;
notebook_order?: Record<string, number>;
} }
export interface SearchResult { export interface SearchResult {
+1 -1
View File
@@ -67,7 +67,7 @@
} }
} }
} }
// Internal .md note link navigate within the app // Internal .md note link - navigate within the app
if (absPath.endsWith('.md')) { if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => { readNote(absPath).then((content) => {
activeNote.set({ ...content, content: content.content }); activeNote.set({ ...content, content: content.content });