mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d11ae6ea0 | ||
|
|
bc372993c4 | ||
|
|
4897703111 | ||
|
|
1bc067d470 | ||
|
|
565688b284 | ||
|
|
e87a2edc00 | ||
|
|
9f595212dd | ||
|
|
79f16b454a |
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -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.6)
|
||||
|
||||
### 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.6/HelixNotes_1.2.6_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.6/HelixNotes_1.2.6_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.6/HelixNotes_1.2.6_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.6/HelixNotes_1.2.6_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.6/HelixNotes_1.2.6_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
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
Generated
+1
-1
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.2.5"
|
||||
version = "1.2.7"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.2.5"
|
||||
version = "1.2.7"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -1361,9 +1376,20 @@ fn resolve_wiki_ref(
|
||||
pub fn open_file(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
let mut cmd = std::process::Command::new("xdg-open");
|
||||
cmd.arg(&path);
|
||||
// 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 {}: {}", path, e))?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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('_') {
|
||||
|
||||
// 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 {
|
||||
// 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(&relative);
|
||||
|
||||
// Recreate parent directories if needed
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let dest = Path::new(vault_path).join(original_name);
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.7",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
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';
|
||||
@@ -252,6 +252,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 +263,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 +290,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;
|
||||
|
||||
@@ -307,17 +307,8 @@
|
||||
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 vaultRoot = $appConfig?.active_vault ?? '';
|
||||
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 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)}`);
|
||||
@@ -326,6 +317,14 @@
|
||||
['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],
|
||||
],
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2086,16 +2085,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>`;
|
||||
}
|
||||
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
|
||||
@@ -2316,20 +2316,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}} : {}),
|
||||
// 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) => {
|
||||
@@ -3792,7 +3778,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if historySelected && historyPreview !== null}
|
||||
{#if historySelected}
|
||||
<div class="history-actions">
|
||||
<button class="history-restore-btn" onclick={restoreVersion}>
|
||||
Restore this version
|
||||
@@ -7194,14 +7180,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -53,16 +54,20 @@
|
||||
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 +85,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 +484,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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,6 +33,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 +75,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 +94,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 +125,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;
|
||||
|
||||
Reference in New Issue
Block a user