Compare commits

...
4 Commits
20 changed files with 647 additions and 38 deletions
+13 -6
View File
@@ -5,7 +5,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 ## Download (v1.1.5)
### Linux ### Linux
@@ -21,21 +21,21 @@ yay -S helixnotes-appimage-bin
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
``` ```
#### AppImage (Arch, Fedora, openSUSE) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.4/HelixNotes_1.1.4_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.1.5/HelixNotes_1.1.5_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.4/HelixNotes_1.1.4_amd64.deb) — Ubuntu 22.04+ [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.5/HelixNotes_1.1.5_amd64.deb) — Ubuntu 22.04+
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.4/HelixNotes_1.1.4_x64-setup.exe) — Windows 10/11 [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.5/HelixNotes_1.1.5_x64-setup.exe) — Windows 10/11
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.4/HelixNotes_1.1.4_x64.dmg) — macOS 12+ (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.5/HelixNotes_1.1.5_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta)
--- ---
@@ -92,6 +92,13 @@ pnpm tauri dev
pnpm tauri build pnpm tauri build
``` ```
## Screenshots
![Editor](https://cdn.helixnotes.com/assets/screenshots/screenshot-1.png)
![Settings](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph View](https://cdn.helixnotes.com/assets/screenshots/screenshot-3.png)
![AI Actions](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.png)
## License ## License
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE) [AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.1.5", "version": "1.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+1 -1
View File
@@ -1750,7 +1750,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.1.5" version = "1.1.6"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs", "dirs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.1.5" version = "1.1.6"
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"
+3 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "HelixNotes default capabilities", "description": "HelixNotes default capabilities",
"windows": ["main"], "windows": ["main", "note-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:default", "core:window:default",
@@ -12,6 +12,8 @@
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-set-decorations", "core:window:allow-set-decorations",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
+14
View File
@@ -45,6 +45,12 @@ pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
Ok(config.clone()) Ok(config.clone())
} }
#[tauri::command]
pub fn get_pending_open_file(state: State<'_, AppState>) -> Result<Option<String>, String> {
let mut pending = state.pending_open_file.lock().map_err(|e| e.to_string())?;
Ok(pending.take())
}
#[tauri::command] #[tauri::command]
pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String> { pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
@@ -77,6 +83,14 @@ pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(),
Ok(()) Ok(())
} }
#[tauri::command]
pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.line_height = Some(height);
save_app_config(&config)?;
Ok(())
}
// ── Notebooks ── // ── Notebooks ──
#[tauri::command] #[tauri::command]
+66 -9
View File
@@ -9,7 +9,7 @@ mod vault;
use state::AppState; use state::AppState;
#[allow(unused_imports)] #[allow(unused_imports)]
use tauri::Manager; use tauri::{Emitter, Manager};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use tauri::{ use tauri::{
@@ -59,6 +59,28 @@ pub fn run() {
setup_tray(app)?; setup_tray(app)?;
} }
// Check CLI args for a .md file path on initial launch
#[cfg(not(target_os = "android"))]
{
let file_arg = std::env::args().skip(1).find(|a| {
let a = a.trim();
!a.starts_with('-') && a.ends_with(".md")
});
if let Some(path) = file_arg {
let resolved = if std::path::Path::new(&path).is_absolute() {
std::path::PathBuf::from(&path)
} else {
std::env::current_dir().unwrap_or_default().join(&path)
};
if let Some(resolved_str) = resolved.to_str() {
let app_state = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string());
});
}
}
}
Ok(()) Ok(())
}) })
.manage(app_state) .manage(app_state)
@@ -69,6 +91,7 @@ pub fn run() {
commands::set_accent_color, commands::set_accent_color,
commands::set_font_size, commands::set_font_size,
commands::set_font_family, commands::set_font_family,
commands::set_line_height,
commands::get_notebooks, commands::get_notebooks,
commands::create_notebook, commands::create_notebook,
commands::rename_notebook, commands::rename_notebook,
@@ -117,28 +140,62 @@ pub fn run() {
commands::test_ai_connection, commands::test_ai_connection,
commands::ai_ask, commands::ai_ask,
commands::get_install_type, commands::get_install_type,
commands::get_pending_open_file,
]); ]);
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
{ {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
// Always show/focus the main window
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.show(); let _ = window.show();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); let _ = window.set_focus();
} }
// Extract .md file path from args (args[0] is the binary)
let file_path = args.iter().skip(1).find(|arg| {
let a = arg.trim();
!a.starts_with('-') && a.ends_with(".md")
});
if let Some(path) = file_path {
let resolved = if std::path::Path::new(path.as_str()).is_absolute() {
std::path::PathBuf::from(path)
} else {
std::path::Path::new(&cwd).join(path)
};
if let Some(resolved_str) = resolved.to_str() {
let _ = app.emit("open-file", resolved_str.to_string());
}
}
})); }));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
if close_to_tray { builder = builder.on_window_event(move |window, event| {
builder = builder.on_window_event(|window, event| { match event {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close(); // Only hide to tray for the main window
let _ = window.hide(); if close_to_tray && window.label() == "main" {
api.prevent_close();
let _ = window.hide();
}
} }
}); tauri::WindowEvent::Destroyed => {
} // When main window is destroyed, close all note windows
if window.label() == "main" {
let app = window.app_handle();
for (label, win) in app.webview_windows() {
if label.starts_with("note-") {
let _ = win.close();
}
}
}
}
_ => {}
}
});
} }
builder builder
+2
View File
@@ -9,6 +9,7 @@ pub struct AppState {
pub search_index: Mutex<Option<SearchIndex>>, pub search_index: Mutex<Option<SearchIndex>>,
pub watcher: Mutex<Option<RecommendedWatcher>>, pub watcher: Mutex<Option<RecommendedWatcher>>,
pub importing: AtomicBool, pub importing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>,
} }
impl AppState { impl AppState {
@@ -18,6 +19,7 @@ impl AppState {
search_index: Mutex::new(None), search_index: Mutex::new(None),
watcher: Mutex::new(None), watcher: Mutex::new(None),
importing: AtomicBool::new(false), importing: AtomicBool::new(false),
pending_open_file: Mutex::new(None),
} }
} }
} }
+3
View File
@@ -54,6 +54,8 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub font_family: Option<String>, pub font_family: Option<String>,
#[serde(default)] #[serde(default)]
pub line_height: Option<f64>,
#[serde(default)]
pub compact_notes: bool, pub compact_notes: bool,
#[serde(default)] #[serde(default)]
pub time_format: String, pub time_format: String,
@@ -142,6 +144,7 @@ impl Default for AppConfig {
accent_color: None, accent_color: None,
font_size: None, font_size: None,
font_family: None, font_family: None,
line_height: None,
compact_notes: false, compact_notes: false,
time_format: "relative".to_string(), time_format: "relative".to_string(),
gpu_acceleration: true, gpu_acceleration: true,
+27 -2
View File
@@ -77,6 +77,29 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
(meta, content) (meta, content)
} }
/// Ensure the body starts with `# Title` heading for external app compatibility.
/// If the body already starts with `# Title`, leave it as-is.
fn ensure_title_heading(body: &str, title: &str) -> String {
let trimmed = body.trim_start();
// Check if body already starts with `# Title`
if let Some(rest) = trimmed.strip_prefix("# ") {
let first_line_end = rest.find('\n').unwrap_or(rest.len());
let heading_text = rest[..first_line_end].trim();
if heading_text.eq_ignore_ascii_case(title.trim()) {
return body.to_string();
}
}
// Don't add heading to empty notes (new/blank notes)
if trimmed.is_empty() {
return body.to_string();
}
// Prepend `# Title` heading
format!("# {}\n\n{}", title, body)
}
pub fn serialize_frontmatter(meta: &NoteMeta) -> String { pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
let tags_str = if meta.tags.is_empty() { let tags_str = if meta.tags.is_empty() {
"[]".to_string() "[]".to_string()
@@ -104,7 +127,8 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String { pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String {
let fm = serialize_frontmatter(meta); let fm = serialize_frontmatter(meta);
format!("{}{}", fm, body) let body_with_heading = ensure_title_heading(body, &meta.title);
format!("{}{}", fm, body_with_heading)
} }
/// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys. /// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys.
@@ -163,7 +187,8 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
Err(_) => return update_note_raw(meta, body), Err(_) => return update_note_raw(meta, body),
}; };
format!("---\n{}---\n{}", yaml_str, body) let body_with_heading = ensure_title_heading(body, &meta.title);
format!("---\n{}---\n{}", yaml_str, body_with_heading)
} }
fn filename_to_title(filename: &str) -> String { fn filename_to_title(filename: &str) -> String {
+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.1.5", "version": "1.1.6",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+8
View File
@@ -38,6 +38,10 @@ export async function setFontFamily(family: string): Promise<void> {
return invoke("set_font_family", { family }); return invoke("set_font_family", { family });
} }
export async function setLineHeight(height: number): Promise<void> {
return invoke("set_line_height", { height });
}
export async function getNotebooks(): Promise<NotebookEntry[]> { export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks"); return invoke("get_notebooks");
} }
@@ -330,3 +334,7 @@ export async function aiAsk(
export async function getInstallType(): Promise<string> { export async function getInstallType(): Promise<string> {
return invoke("get_install_type"); return invoke("get_install_type");
} }
export async function getPendingOpenFile(): Promise<string | null> {
return invoke("get_pending_open_file");
}
+39 -2
View File
@@ -28,14 +28,16 @@
showInfo, showInfo,
showSettings, showSettings,
sourceMode, sourceMode,
mobileView mobileView,
appConfig
} 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);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { VaultState, FileEvent } from '$lib/types'; import type { VaultState, FileEvent } from '$lib/types';
@@ -43,6 +45,7 @@
let noteList: NoteList; let noteList: NoteList;
let editor: Editor; let editor: Editor;
let unlistenFileChange: (() => void) | null = null; let unlistenFileChange: (() => void) | null = null;
let unlistenOpenFile: (() => void) | null = null;
let backupInterval: ReturnType<typeof setInterval> | null = null; let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false; let navigatingFromHistory = false;
let noteHistory: string[] = []; let noteHistory: string[] = [];
@@ -104,6 +107,22 @@
}); });
} }
async function handleOpenFile(filePath: string) {
if (!filePath || !filePath.endsWith('.md')) return;
const config = get(appConfig);
const vaultRoot = config?.active_vault;
if (!vaultRoot || !filePath.startsWith(vaultRoot)) return;
try {
const content = await readNote(filePath);
$activeNote = content;
$activeNotePath = filePath;
$editorDirty = false;
editor?.loadNote(filePath, content.content);
} catch (e) {
console.error('Failed to open file:', e);
}
}
const persistState = debounce(async () => { const persistState = debounce(async () => {
const state: VaultState = { const state: VaultState = {
last_open_note: null, last_open_note: null,
@@ -219,6 +238,12 @@
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
if (mod && e.shiftKey && e.key === 'W') {
e.preventDefault();
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
@@ -268,6 +293,17 @@
await noteList?.refresh(true); await noteList?.refresh(true);
}); });
// Listen for file open events (from single-instance or OS file associations)
unlistenOpenFile = await listen<string>('open-file', async (event) => {
await handleOpenFile(event.payload);
});
// Check for pending file from first-launch CLI args
try {
const pending = await getPendingOpenFile();
if (pending) await handleOpenFile(pending);
} catch (_) {}
// Scheduled backup: check on startup and every 5 minutes // Scheduled backup: check on startup and every 5 minutes
checkScheduledBackup(); checkScheduledBackup();
backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000); backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000);
@@ -275,6 +311,7 @@
onDestroy(() => { onDestroy(() => {
unlistenFileChange?.(); unlistenFileChange?.();
unlistenOpenFile?.();
if (backupInterval) clearInterval(backupInterval); if (backupInterval) clearInterval(backupInterval);
}); });
</script> </script>
+33 -12
View File
@@ -39,7 +39,7 @@
import { openFile, copyFileTo } from '$lib/api'; import { openFile, copyFileTo } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app'; import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote } from '$lib/api'; import { saveNote, saveImage, saveAttachment, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types'; import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
@@ -2941,16 +2941,37 @@
editor?.commands.focus('start'); editor?.commands.focus('start');
} }
}} }}
onchange={(e) => { onchange={async (e) => {
if ($activeNote) { if ($activeNote && $activeNotePath) {
const newTitle = (e.target as HTMLInputElement).value; const newTitle = (e.target as HTMLInputElement).value.trim();
if (!newTitle) return;
const oldPath = $activeNotePath;
$activeNote.meta.title = newTitle; $activeNote.meta.title = newTitle;
// Update the note list entry so the 2nd panel reflects the change
notes.update(list => list.map(n =>
n.path === $activeNotePath ? { ...n, meta: { ...n.meta, title: newTitle } } : n
));
$editorDirty = true; $editorDirty = true;
autoSave(); autoSave();
// Rename file on disk if filename doesn't match the new title
const filename = oldPath.split('/').pop() ?? '';
const stem = filename.replace(/\.md$/, '');
if (stem !== newTitle) {
try {
const newPath = await renameNote(oldPath, newTitle);
$activeNotePath = newPath;
notes.update(list => list.map(n =>
n.path === oldPath
? { ...n, path: newPath, relative_path: n.relative_path.replace(/[^/]+$/, newTitle + '.md'), meta: { ...n.meta, title: newTitle } }
: n
));
} catch (err) {
console.error('Failed to rename note file:', err);
notes.update(list => list.map(n =>
n.path === oldPath ? { ...n, meta: { ...n.meta, title: newTitle } } : n
));
}
} else {
notes.update(list => list.map(n =>
n.path === oldPath ? { ...n, meta: { ...n.meta, title: newTitle } } : n
));
}
} }
}} }}
/> />
@@ -4817,7 +4838,7 @@
color: var(--text-primary); color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px); font-size: var(--editor-font-size, 14px);
line-height: 1.6; line-height: var(--editor-line-height, 1.6);
resize: none; resize: none;
outline: none; outline: none;
padding: 0; padding: 0;
@@ -4845,7 +4866,7 @@
padding-top: 8px; padding-top: 8px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px); font-size: var(--editor-font-size, 14px);
line-height: 1.6; line-height: var(--editor-line-height, 1.6);
color: var(--text-secondary); color: var(--text-secondary);
opacity: 0.5; opacity: 0.5;
text-align: right; text-align: right;
@@ -4874,7 +4895,7 @@
:global(.tiptap-wrapper .tiptap p) { :global(.tiptap-wrapper .tiptap p) {
margin: 0 0 0.75em; margin: 0 0 0.75em;
line-height: 1.65; line-height: var(--editor-line-height, 1.65);
} }
:global(.tiptap-wrapper .tiptap h1) { :global(.tiptap-wrapper .tiptap h1) {
@@ -5192,7 +5213,7 @@
:global(.tiptap-wrapper .tiptap li) { :global(.tiptap-wrapper .tiptap li) {
margin: 0.25em 0; margin: 0.25em 0;
line-height: 1.65; line-height: var(--editor-line-height, 1.65);
} }
:global(.tiptap-wrapper .tiptap li p) { :global(.tiptap-wrapper .tiptap li p) {
+7
View File
@@ -32,6 +32,7 @@
getAllTags getAllTags
} 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 type { NoteEntry, SortMode } from '$lib/types'; import type { NoteEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {} }: {
@@ -936,6 +937,12 @@
Add to Quick Access Add to Quick Access
</button> </button>
{/if} {/if}
<button onclick={() => { const n = contextMenu!.note; contextMenu = null; openNoteWindow(n.path, n.meta.title); }}>
<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="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Open in New Window
</button>
<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"/>
+326
View File
@@ -0,0 +1,326 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import Editor from './Editor.svelte';
import {
activeNote,
activeNotePath,
editorDirty,
readOnly,
sourceMode,
theme
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import type { FileEvent } from '$lib/types';
let { notePath }: { notePath: string } = $props();
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!);
let unlistenFileChange: (() => void) | null = null;
let maximized = $state(false);
let loadError = $state<string | null>(null);
async function checkMaximized() {
maximized = await appWindow.isMaximized();
}
$effect(() => {
checkMaximized();
const unlisten = appWindow.onResized(() => checkMaximized());
return () => { unlisten.then(fn => fn()); };
});
$effect(() => {
const themeValue = $theme || 'system';
const root = document.documentElement;
root.classList.remove('dark');
if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
});
let lastMouseDown = 0;
const RESIZE_EDGE = 6;
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
if (!maximized) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return;
}
const now = Date.now();
if (now - lastMouseDown < 300) {
appWindow.toggleMaximize();
lastMouseDown = 0;
return;
}
lastMouseDown = now;
appWindow.startDragging();
}
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') {
e.preventDefault();
editor?.forceSave();
}
if (mod && e.shiftKey && e.key === 'M') {
e.preventDefault();
$sourceMode = !$sourceMode;
}
}
onMount(async () => {
try {
const content = await readNote(notePath);
$activeNote = content;
$activeNotePath = notePath;
$editorDirty = false;
setTimeout(() => {
editor?.loadNote(notePath, content.content);
appWindow.setTitle(`${content.meta.title} - HelixNotes`);
}, 50);
} catch (e) {
loadError = String(e);
}
unlistenFileChange = await listen<FileEvent>('file-changed', async (event) => {
if (event.payload.path === notePath && event.payload.event_type === 'modify') {
if (!$editorDirty) {
try {
const content = await readNote(notePath);
$activeNote = content;
editor?.loadNote(notePath, content.content);
} catch (_) {}
}
}
});
});
onDestroy(() => {
unlistenFileChange?.();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="note-window">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="nw-titlebar" class:macos={isMac} onmousedown={handleMouseDown}>
<div class="nw-titlebar-brand">
<svg width="14" height="14" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="var(--accent)" />
<circle cx="16" cy="16" r="3.5" fill="white" opacity="0.9" />
<circle cx="32" cy="16" r="3.5" fill="white" opacity="0.9" />
<circle cx="16" cy="32" r="3.5" fill="white" opacity="0.9" />
<circle cx="32" cy="32" r="3.5" fill="white" opacity="0.9" />
<line x1="19" y1="18" x2="29" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.7" />
<line x1="29" y1="18" x2="19" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.7" />
</svg>
<span class="nw-title">{$activeNote?.meta.title || 'Loading...'}</span>
{#if $editorDirty}
<span class="nw-dirty-dot" title="Unsaved changes"></span>
{/if}
</div>
<div class="titlebar-actions">
<button class="nw-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title="Toggle Source Mode">
<svg width="14" height="14" 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>
</button>
<button class="nw-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)} title={$readOnly ? 'Switch to Edit Mode' : 'Switch to View Mode'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if $readOnly}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
{:else}
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
{/if}
</svg>
</button>
</div>
{#if !isMac}
<div class="titlebar-controls">
<button class="titlebar-btn" onclick={() => appWindow.minimize()} title="Minimize">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="5" x2="9" y2="5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<button class="titlebar-btn" onclick={() => appWindow.toggleMaximize()} title={maximized ? 'Restore' : 'Maximize'}>
{#if maximized}
<svg width="10" height="10" viewBox="0 0 10 10">
<rect x="2.5" y="0.5" width="7" height="7" rx="1" fill="none" stroke="currentColor" stroke-width="1.2" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1" fill="var(--bg-secondary)" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else}
<svg width="10" height="10" viewBox="0 0 10 10">
<rect x="1" y="1" width="8" height="8" rx="1" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>
{/if}
</button>
<button class="titlebar-btn titlebar-close" onclick={() => appWindow.close()} title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
</div>
{/if}
</div>
{#if loadError}
<div class="nw-error">
<p>Failed to load note</p>
<p class="nw-error-detail">{loadError}</p>
</div>
{:else}
<div class="nw-editor">
<Editor bind:this={editor} />
</div>
{/if}
</div>
<style>
.note-window {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
}
.nw-titlebar {
display: flex;
align-items: center;
height: 34px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
user-select: none;
flex-shrink: 0;
-webkit-app-region: drag;
}
.nw-titlebar.macos {
padding-left: 78px;
}
.nw-titlebar-brand {
display: flex;
align-items: center;
gap: 8px;
padding-left: 14px;
pointer-events: none;
flex: 1;
min-width: 0;
}
.nw-title {
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nw-dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
.titlebar-actions {
display: flex;
align-items: center;
gap: 4px;
margin-right: 8px;
-webkit-app-region: no-drag;
}
.nw-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 7px;
background: var(--bg-hover);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.nw-btn:hover {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.nw-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.titlebar-controls {
display: flex;
height: 100%;
-webkit-app-region: no-drag;
}
.titlebar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 100%;
border: none;
background: none;
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.titlebar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.titlebar-close:hover {
background: #e81123;
color: white;
}
.nw-editor {
flex: 1;
overflow: hidden;
}
.nw-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
}
.nw-error-detail {
font-size: 12px;
color: var(--text-tertiary);
max-width: 400px;
text-align: center;
word-break: break-word;
}
</style>
+41 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab } from '$lib/stores/app'; import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, 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';
import { check as checkUpdate } from '@tauri-apps/plugin-updater'; import { check as checkUpdate } from '@tauri-apps/plugin-updater';
@@ -293,9 +293,17 @@
{ name: 'Mono', value: 'mono', stack: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace' }, { name: 'Mono', value: 'mono', stack: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace' },
]; ];
const lineHeightPresets = [
{ label: 'Tight', value: 1.4 },
{ label: 'Default', value: 1.6 },
{ label: 'Relaxed', value: 1.8 },
{ label: 'Loose', value: 2.0 },
];
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo'); let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let activeFontSize = $state($appConfig?.font_size ?? 14); let activeFontSize = $state($appConfig?.font_size ?? 14);
let activeFontFamily = $state($appConfig?.font_family ?? 'system'); let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
// General settings // General settings
let compactNotes = $state($appConfig?.compact_notes ?? false); let compactNotes = $state($appConfig?.compact_notes ?? false);
@@ -422,6 +430,17 @@
document.documentElement.style.setProperty('--editor-font-family', stack); document.documentElement.style.setProperty('--editor-font-family', stack);
} }
function selectLineHeight(value: number) {
activeLineHeight = value;
applyLineHeight(value);
if ($appConfig) $appConfig.line_height = value;
setLineHeight(value).catch((e) => console.error('Failed to save line height:', e));
}
function applyLineHeight(value: number) {
document.documentElement.style.setProperty('--editor-line-height', String(value));
}
function close() { function close() {
// Ensure AI settings are saved when closing (in case input wasn't blurred) // Ensure AI settings are saved when closing (in case input wasn't blurred)
// Only save if the key differs from what's already stored // Only save if the key differs from what's already stored
@@ -454,6 +473,11 @@
applyFontFamily(preset.stack); applyFontFamily(preset.stack);
} }
} }
const savedLineHeight = $appConfig?.line_height;
if (savedLineHeight) {
activeLineHeight = savedLineHeight;
applyLineHeight(savedLineHeight);
}
// Sync general settings // Sync general settings
if ($appConfig) { if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false; compactNotes = $appConfig.compact_notes ?? false;
@@ -748,6 +772,22 @@
</div> </div>
</div> </div>
<div class="settings-section">
<h3>Line Height</h3>
<div class="font-size-options">
{#each lineHeightPresets as preset}
<button
class="font-size-btn"
class:active={activeLineHeight === preset.value}
onclick={() => selectLineHeight(preset.value)}
>
<span class="font-size-label">{preset.label}</span>
<span class="font-size-value">{preset.value}</span>
</button>
{/each}
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3>Font</h3> <h3>Font</h3>
<div class="font-family-options"> <div class="font-family-options">
+1
View File
@@ -41,6 +41,7 @@ export interface AppConfig {
accent_color: string | null; accent_color: string | null;
font_size: number | null; font_size: number | null;
font_family: string | null; font_family: string | null;
line_height: number | null;
compact_notes: boolean; compact_notes: boolean;
time_format: string; time_format: string;
gpu_acceleration: boolean; gpu_acceleration: boolean;
+42
View File
@@ -0,0 +1,42 @@
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
const openNoteWindows = new Map<string, string>();
export async function openNoteWindow(notePath: string, noteTitle: string) {
const existingLabel = openNoteWindows.get(notePath);
if (existingLabel) {
const existing = await WebviewWindow.getByLabel(existingLabel);
if (existing) {
await existing.show();
await existing.unminimize();
await existing.setFocus();
return;
}
openNoteWindows.delete(notePath);
}
const label = `note-${Date.now()}`;
const url = `/?note=${encodeURIComponent(notePath)}`;
const noteWindow = new WebviewWindow(label, {
url,
title: `${noteTitle} - HelixNotes`,
width: 900,
height: 700,
minWidth: 500,
minHeight: 400,
decorations: false,
center: true
});
openNoteWindows.set(notePath, label);
noteWindow.once('tauri://destroyed', () => {
openNoteWindows.delete(notePath);
});
noteWindow.once('tauri://error', (e) => {
console.error('Failed to create note window:', e);
openNoteWindows.delete(notePath);
});
}
+18 -1
View File
@@ -4,10 +4,19 @@
import { getAppConfig, openVault } from '$lib/api'; import { getAppConfig, openVault } from '$lib/api';
import VaultPicker from '$lib/components/VaultPicker.svelte'; import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte'; import AppLayout from '$lib/components/AppLayout.svelte';
import NoteWindow from '$lib/components/NoteWindow.svelte';
let loading = $state(true); let loading = $state(true);
let noteWindowPath = $state<string | null>(null);
onMount(async () => { onMount(async () => {
// Check for note window mode
const params = new URLSearchParams(window.location.search);
const notePath = params.get('note');
if (notePath) {
noteWindowPath = notePath;
}
try { try {
const config = await getAppConfig(); const config = await getAppConfig();
$appConfig = config; $appConfig = config;
@@ -25,6 +34,9 @@
if (config.font_size) { if (config.font_size) {
document.documentElement.style.setProperty('--editor-font-size', `${config.font_size}px`); document.documentElement.style.setProperty('--editor-font-size', `${config.font_size}px`);
} }
if (config.line_height) {
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
}
if (config.font_family) { if (config.font_family) {
const fontStacks: Record<string, string> = { const fontStacks: Record<string, string> = {
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
@@ -69,7 +81,10 @@
// Auto-open last vault if available // Auto-open last vault if available
if (config.active_vault) { if (config.active_vault) {
try { try {
await openVault(config.active_vault); // Note windows don't re-open the vault (already open in main process)
if (!noteWindowPath) {
await openVault(config.active_vault);
}
$vaultReady = true; $vaultReady = true;
} catch { } catch {
// Vault path might not exist anymore // Vault path might not exist anymore
@@ -86,6 +101,8 @@
<div class="loading"> <div class="loading">
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
{:else if noteWindowPath}
<NoteWindow notePath={noteWindowPath} />
{:else if $vaultReady} {:else if $vaultReady}
<AppLayout /> <AppLayout />
{:else} {:else}