From 42c1594f0e67877b80044ffb45a3891d22056c47 Mon Sep 17 00:00:00 2001 From: "Timo S." Date: Thu, 12 Feb 2026 12:14:39 +0100 Subject: [PATCH 1/4] Customize Shortcuts + MacOS MenuBar --- AGENTS.md | 1 + package-lock.json | 24 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 292 ++++++++++++------ src/App.tsx | 70 ++++- .../command-palette/CommandPalette.tsx | 11 +- src/components/editor/Editor.tsx | 188 ++++++++--- src/components/settings/SettingsPage.tsx | 79 +++-- .../settings/ShortcutsSettingsSection.tsx | 245 +++++++++++---- src/context/ThemeContext.tsx | 62 ++++ src/lib/shortcuts.ts | 251 +++++++++++++++ src/types/note.ts | 20 ++ 12 files changed, 1012 insertions(+), 233 deletions(-) create mode 120000 AGENTS.md create mode 100644 src/lib/shortcuts.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3d277fc..cff6401 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tauri-app", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tauri-app", - "version": "0.2.0", + "version": "0.4.0", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -80,6 +80,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -787,6 +788,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" @@ -2441,6 +2443,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz", "integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2676,6 +2679,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.19.0.tgz", "integrity": "sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2768,6 +2772,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.19.0.tgz", "integrity": "sha512-Lg8DlkkDUMYE/CcGOxoCWF98B2i7VWh+AGgqlF+XWrHjhlKHfENLRXm1a0vWuyyP3NknRYILoaaZ1s7QzmXKRA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2873,6 +2878,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.19.0.tgz", "integrity": "sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2904,6 +2910,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz", "integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3085,6 +3092,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3094,6 +3102,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3173,6 +3182,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3874,6 +3884,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4036,6 +4047,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -4065,6 +4077,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -4113,6 +4126,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -4133,6 +4147,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4142,6 +4157,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4330,7 +4346,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -4494,6 +4511,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4327962..f432736 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2eecd64..6870dbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,10 @@ use tantivy::collector::TopDocs; use tantivy::query::QueryParser; use tantivy::schema::*; use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy}; +#[cfg(target_os = "macos")] +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +#[cfg(target_os = "macos")] +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_clipboard_manager::ClipboardExt; use tokio::fs; @@ -95,6 +99,7 @@ pub struct Settings { pub git_enabled: Option, #[serde(rename = "pinnedNoteIds")] pub pinned_note_ids: Option>, + pub shortcuts: Option>, } // Search result @@ -290,8 +295,8 @@ impl SearchIndex { // App state with improved structure pub struct AppState { - pub app_config: RwLock, // notes_folder path (stored in app data) - pub settings: RwLock, // per-folder settings (stored in .scratch/) + pub app_config: RwLock, // notes_folder path (stored in app data) + pub settings: RwLock, // per-folder settings (stored in .scratch/) pub notes_cache: RwLock>, pub file_watcher: Mutex>, pub search_index: Mutex>, @@ -382,7 +387,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("~~") { if let Some(end) = result[start + 2..].find("~~") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -392,7 +402,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("**") { if let Some(end) = result[start + 2..].find("**") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -400,7 +415,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("__") { if let Some(end) = result[start + 2..].find("__") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -410,7 +430,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find('`') { if let Some(end) = result[start + 1..].find('`') { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -430,7 +455,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('*') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -443,7 +473,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('_') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -668,9 +703,9 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str let b_pinned = pinned_ids.contains(&b.id); match (a_pinned, b_pinned) { - (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first + (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first (false, true) => std::cmp::Ordering::Greater, // b pinned, a not -> b first - _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) + _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) } }); @@ -704,9 +739,7 @@ async fn read_note(id: String, state: State<'_, AppState>) -> Result) -> Settings { } #[tauri::command] -fn update_settings( - new_settings: Settings, - state: State, -) -> Result<(), String> { +fn update_settings(new_settings: Settings, state: State) -> Result<(), String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? }; { @@ -935,7 +970,10 @@ fn update_settings( } #[tauri::command] -async fn search_notes(query: String, state: State<'_, AppState>) -> Result, String> { +async fn search_notes( + query: String, + state: State<'_, AppState>, +) -> Result, String> { if query.trim().is_empty() { return Ok(vec![]); } @@ -959,7 +997,10 @@ async fn search_notes(query: String, state: State<'_, AppState>) -> Result) -> Result, String> { +async fn fallback_search( + query: &str, + state: &State<'_, AppState>, +) -> Result, String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); app_config.notes_folder.clone() @@ -1022,7 +1063,11 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result 100 { - map.retain(|_, last| now.duration_since(*last) < Duration::from_secs(5)); + map.retain(|_, last| { + now.duration_since(*last) < Duration::from_secs(5) + }); } if let Some(last) = map.get(path) { @@ -1094,10 +1141,13 @@ fn setup_file_watcher( let modified = std::fs::metadata(path) .ok() .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH).ok() + }) .map(|d| d.as_secs() as i64) .unwrap_or(0); - let _ = search_index.index_note(¬e_id, &title, &content, modified); + let _ = search_index + .index_note(¬e_id, &title, &content, modified); } } "deleted" => { @@ -1147,11 +1197,7 @@ fn start_file_watcher(app: AppHandle, state: State) -> Result<(), Stri // Clean up debounce map before starting cleanup_debounce_map(&state.debounce_map); - let watcher_state = setup_file_watcher( - app, - &folder, - Arc::clone(&state.debounce_map), - )?; + let watcher_state = setup_file_watcher(app, &folder, Arc::clone(&state.debounce_map))?; let mut file_watcher = state.file_watcher.lock().expect("file watcher mutex"); *file_watcher = Some(watcher_state); @@ -1416,11 +1462,9 @@ async fn git_get_status(state: State<'_, AppState>) -> Result { - tauri::async_runtime::spawn_blocking(move || { - git::get_status(&PathBuf::from(path)) - }) - .await - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || git::get_status(&PathBuf::from(path))) + .await + .map_err(|e| e.to_string()) } None => Ok(git::GitStatus::default()), } @@ -1430,14 +1474,15 @@ async fn git_get_status(state: State<'_, AppState>) -> Result) -> Result<(), String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? }; - tauri::async_runtime::spawn_blocking(move || { - git::git_init(&PathBuf::from(folder)) - }) - .await - .map_err(|e| e.to_string())? + tauri::async_runtime::spawn_blocking(move || git::git_init(&PathBuf::from(folder))) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -1448,13 +1493,11 @@ async fn git_commit(message: String, state: State<'_, AppState>) -> Result { - tauri::async_runtime::spawn_blocking(move || { - git::commit_all(&PathBuf::from(path), &message) - }) - .await - .map_err(|e| e.to_string()) - } + Some(path) => tauri::async_runtime::spawn_blocking(move || { + git::commit_all(&PathBuf::from(path), &message) + }) + .await + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -1471,13 +1514,9 @@ async fn git_push(state: State<'_, AppState>) -> Result }; match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::push(&PathBuf::from(path)) - }) + Some(path) => tauri::async_runtime::spawn_blocking(move || git::push(&PathBuf::from(path))) .await - .map_err(|e| e.to_string()) - } + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -1494,13 +1533,11 @@ async fn git_add_remote(url: String, state: State<'_, AppState>) -> Result { - tauri::async_runtime::spawn_blocking(move || { - git::add_remote(&PathBuf::from(path), &url) - }) - .await - .map_err(|e| e.to_string()) - } + Some(path) => tauri::async_runtime::spawn_blocking(move || { + git::add_remote(&PathBuf::from(path), &url) + }) + .await + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -1606,12 +1643,9 @@ async fn ai_check_claude_cli() -> Result { // AI execute command #[tauri::command] -async fn ai_execute_claude( - file_path: String, - prompt: String, -) -> Result { - use std::process::{Child, Command, Stdio}; +async fn ai_execute_claude(file_path: String, prompt: String) -> Result { use std::io::Write; + use std::process::{Child, Command, Stdio}; // Check if claude CLI exists let path = get_expanded_path(); @@ -1673,7 +1707,9 @@ async fn ai_execute_claude( return AiExecutionResult { success: false, output: String::new(), - error: Some("Claude process handle was unexpectedly missing".to_string()), + error: Some( + "Claude process handle was unexpectedly missing".to_string(), + ), }; } }, @@ -1727,22 +1763,18 @@ async fn ai_execute_claude( } } } - Err(e) => { - AiExecutionResult { - success: false, - output: String::new(), - error: Some(format!("Failed to wait for claude: {}", e)), - } - } - } - } - Err(e) => { - AiExecutionResult { - success: false, - output: String::new(), - error: Some(format!("Failed to execute claude: {}", e)), + Err(e) => AiExecutionResult { + success: false, + output: String::new(), + error: Some(format!("Failed to wait for claude: {}", e)), + }, } } + Err(e) => AiExecutionResult { + success: false, + output: String::new(), + error: Some(format!("Failed to execute claude: {}", e)), + }, } }); @@ -1784,6 +1816,33 @@ async fn ai_execute_claude( Ok(result) } + +#[cfg(target_os = "macos")] +const TRAY_MENU_OPEN: &str = "tray_open"; +#[cfg(target_os = "macos")] +const TRAY_MENU_OPEN_SETTINGS: &str = "tray_open_settings"; +#[cfg(target_os = "macos")] +const TRAY_MENU_QUIT: &str = "tray_quit"; + +#[cfg(target_os = "macos")] +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + if let Ok(true) = window.is_minimized() { + let _ = window.unminimize(); + } + let _ = window.show(); + let _ = window.set_focus(); + } +} + +#[cfg(target_os = "macos")] +fn show_settings_window(app: &AppHandle) { + show_main_window(app); + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("tray-open-settings", ()); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -1792,7 +1851,64 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .on_window_event(|window, event| { + #[cfg(target_os = "macos")] + if window.label() == "main" + && matches!(event, tauri::WindowEvent::CloseRequested { .. }) + { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window.hide(); + } + } + }) .setup(|app| { + #[cfg(target_os = "macos")] + { + let open_item = MenuItem::with_id(app, TRAY_MENU_OPEN, "Open", true, None::<&str>)?; + let open_settings_item = MenuItem::with_id( + app, + TRAY_MENU_OPEN_SETTINGS, + "Settings", + true, + None::<&str>, + )?; + let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "Quit", true, None::<&str>)?; + let separator = PredefinedMenuItem::separator(app)?; + let tray_menu = Menu::with_items( + app, + &[&open_item, &open_settings_item, &separator, &quit_item], + )?; + + let mut tray_builder = TrayIconBuilder::with_id("main-tray") + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| { + if event.id() == TRAY_MENU_OPEN { + show_main_window(app); + } else if event.id() == TRAY_MENU_OPEN_SETTINGS { + show_settings_window(app); + } else if event.id() == TRAY_MENU_QUIT { + app.exit(0); + } + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }); + + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } + let _ = tray_builder.build(app)?; + } + // Load app config on startup (contains notes folder path) let app_config = load_app_config(app.handle()); @@ -1806,11 +1922,9 @@ pub fn run() { // Initialize search index if notes folder is set let search_index = if let Some(ref folder) = app_config.notes_folder { if let Ok(index_path) = get_search_index_path(app.handle()) { - SearchIndex::new(&index_path) - .ok() - .inspect(|idx| { - let _ = idx.rebuild_index(&PathBuf::from(folder)); - }) + SearchIndex::new(&index_path).ok().inspect(|idx| { + let _ = idx.rebuild_index(&PathBuf::from(folder)); + }) } else { None } diff --git a/src/App.tsx b/src/App.tsx index a2c6767..ac57053 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { toast } from "sonner"; import { NotesProvider, useNotes } from "./context/NotesContext"; -import { ThemeProvider } from "./context/ThemeContext"; +import { ThemeProvider, useTheme } from "./context/ThemeContext"; import { GitProvider } from "./context/GitContext"; import { TooltipProvider, Toaster } from "./components/ui"; import { Sidebar } from "./components/layout/Sidebar"; @@ -16,7 +16,9 @@ import { check as checkForUpdate, type Update, } from "@tauri-apps/plugin-updater"; +import { listen } from "@tauri-apps/api/event"; import * as aiService from "./services/ai"; +import { matchesParsedShortcut, parseShortcut } from "./lib/shortcuts"; type ViewState = "notes" | "settings"; @@ -33,6 +35,7 @@ function AppContent() { reloadCurrentNote, currentNote, } = useNotes(); + const { shortcuts } = useTheme(); const [paletteOpen, setPaletteOpen] = useState(false); const [view, setView] = useState("notes"); const [sidebarVisible, setSidebarVisible] = useState(true); @@ -51,6 +54,23 @@ function AppContent() { setView("notes"); }, []); + // Open settings when requested by the macOS tray menu + useEffect(() => { + let unlisten: (() => void) | null = null; + + const setup = async () => { + unlisten = await listen("tray-open-settings", () => { + setView("settings"); + }); + }; + + setup(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { setAiModalOpen(false); @@ -116,14 +136,25 @@ function AppContent() { // Global keyboard shortcuts useEffect(() => { + const openSettingsShortcut = parseShortcut(shortcuts.openSettings); + const commandPaletteShortcut = parseShortcut(shortcuts.openCommandPalette); + const toggleSidebarShortcut = parseShortcut(shortcuts.toggleSidebar); + const createNoteShortcut = parseShortcut(shortcuts.createNote); + const reloadCurrentNoteShortcut = parseShortcut(shortcuts.reloadCurrentNote); + const navigateNoteUpShortcut = parseShortcut(shortcuts.navigateNoteUp); + const navigateNoteDownShortcut = parseShortcut(shortcuts.navigateNoteDown); + const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const isInEditor = target.closest(".ProseMirror"); const isInInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; - // Cmd+, - Toggle settings (always works, even in settings) - if ((e.metaKey || e.ctrlKey) && e.key === ",") { + // Open settings always works, even while already on settings. + if ( + openSettingsShortcut && + matchesParsedShortcut(e, openSettingsShortcut) + ) { e.preventDefault(); toggleSettings(); return; @@ -141,29 +172,34 @@ function AppContent() { return; } - // Cmd+P - Open command palette - if ((e.metaKey || e.ctrlKey) && e.key === "p") { + if ( + commandPaletteShortcut && + matchesParsedShortcut(e, commandPaletteShortcut) + ) { e.preventDefault(); setPaletteOpen(true); return; } - // Cmd+\ - Toggle sidebar - if ((e.metaKey || e.ctrlKey) && e.key === "\\") { + if ( + toggleSidebarShortcut && + matchesParsedShortcut(e, toggleSidebarShortcut) + ) { e.preventDefault(); toggleSidebar(); return; } - // Cmd+N - New note - if ((e.metaKey || e.ctrlKey) && e.key === "n") { + if (createNoteShortcut && matchesParsedShortcut(e, createNoteShortcut)) { e.preventDefault(); createNote(); return; } - // Cmd+R - Reload current note (pull external changes) - if ((e.metaKey || e.ctrlKey) && e.key === "r") { + if ( + reloadCurrentNoteShortcut && + matchesParsedShortcut(e, reloadCurrentNoteShortcut) + ) { e.preventDefault(); reloadCurrentNote(); return; @@ -171,14 +207,21 @@ function AppContent() { // Arrow keys for note navigation (when not in editor or input) if (!isInEditor && !isInInput && displayItems.length > 0) { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { + const shouldNavigateDown = + navigateNoteDownShortcut && + matchesParsedShortcut(e, navigateNoteDownShortcut); + const shouldNavigateUp = + navigateNoteUpShortcut && + matchesParsedShortcut(e, navigateNoteUpShortcut); + + if (shouldNavigateDown || shouldNavigateUp) { e.preventDefault(); const currentIndex = displayItems.findIndex( (n) => n.id === selectedNoteId, ); let newIndex: number; - if (e.key === "ArrowDown") { + if (shouldNavigateDown) { newIndex = currentIndex < displayItems.length - 1 ? currentIndex + 1 : 0; } else { @@ -239,6 +282,7 @@ function AppContent() { toggleSettings, toggleSidebar, view, + shortcuts, ]); const handleClosePalette = useCallback(() => { diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index c792eac..a87f0af 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -38,7 +38,7 @@ import { PinIcon, ClaudeIcon, } from "../icons"; -import { mod } from "../../lib/platform"; +import { getShortcutDisplayText } from "../../lib/shortcuts"; interface Command { id: string; @@ -71,7 +71,7 @@ export function CommandPalette({ pinNote, unpinNote, } = useNotes(); - const { theme, setTheme } = useTheme(); + const { setTheme, shortcuts } = useTheme(); const { status, gitAvailable, commit, push } = useGit(); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); @@ -97,7 +97,7 @@ export function CommandPalette({ { id: "new-note", label: "New Note", - shortcut: `${mod} N`, + shortcut: getShortcutDisplayText(shortcuts.createNote), icon: , action: () => { createNote(); @@ -255,7 +255,7 @@ export function CommandPalette({ { id: "settings", label: "Settings", - shortcut: `${mod} ,`, + shortcut: getShortcutDisplayText(shortcuts.openSettings), icon: , action: () => { onOpenSettings?.(); @@ -300,7 +300,6 @@ export function CommandPalette({ onOpenSettings, onOpenAiModal, setTheme, - theme, gitAvailable, status, commit, @@ -310,6 +309,8 @@ export function CommandPalette({ settings, pinNote, unpinNote, + shortcuts.createNote, + shortcuts.openSettings, ]); // Debounced search using Tantivy (local state, doesn't affect sidebar) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 72f253c..a6be39a 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -36,9 +36,16 @@ function isAllowedUrlScheme(url: string): boolean { import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { useNotes } from "../../context/NotesContext"; +import { useTheme } from "../../context/ThemeContext"; import { LinkEditor } from "./LinkEditor"; import { SearchToolbar } from "./SearchToolbar"; import { cn } from "../../lib/utils"; +import { + DEFAULT_SHORTCUTS, + getShortcutDisplayText, + matchesParsedShortcut, + parseShortcut, +} from "../../lib/shortcuts"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; import type { Settings } from "../../types/note"; @@ -170,11 +177,21 @@ interface FormatBarProps { editor: TiptapEditor | null; onAddLink: () => void; onAddImage: () => void; + boldShortcutLabel: string; + italicShortcutLabel: string; + addLinkShortcutLabel: string; } // FormatBar must re-render with parent to reflect editor.isActive() state changes // (editor instance is mutable, so memo would cause stale active states) -function FormatBar({ editor, onAddLink, onAddImage }: FormatBarProps) { +function FormatBar({ + editor, + onAddLink, + onAddImage, + boldShortcutLabel, + italicShortcutLabel, + addLinkShortcutLabel, +}: FormatBarProps) { const [tableMenuOpen, setTableMenuOpen] = useState(false); if (!editor) return null; @@ -184,14 +201,14 @@ function FormatBar({ editor, onAddLink, onAddImage }: FormatBarProps) { editor.chain().focus().toggleBold().run()} isActive={editor.isActive("bold")} - title={`Bold (${mod}${isMac ? "" : "+"}B)`} + title={`Bold (${boldShortcutLabel})`} > editor.chain().focus().toggleItalic().run()} isActive={editor.isActive("italic")} - title={`Italic (${mod}${isMac ? "" : "+"}I)`} + title={`Italic (${italicShortcutLabel})`} > @@ -291,7 +308,7 @@ function FormatBar({ editor, onAddLink, onAddImage }: FormatBarProps) { @@ -338,6 +355,7 @@ interface EditorProps { } export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { + const { shortcuts } = useTheme(); const { notes, currentNote, @@ -1086,65 +1104,128 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { } }, [editor]); - // Keyboard shortcut for Cmd+K to add link (only when editor is focused) + // Keyboard shortcuts for bold/italic (customizable). useEffect(() => { + const boldShortcut = parseShortcut(shortcuts.bold); + const italicShortcut = parseShortcut(shortcuts.italic); + const defaultBoldShortcut = parseShortcut(DEFAULT_SHORTCUTS.bold); + const defaultItalicShortcut = parseShortcut(DEFAULT_SHORTCUTS.italic); + const boldChanged = shortcuts.bold !== DEFAULT_SHORTCUTS.bold; + const italicChanged = shortcuts.italic !== DEFAULT_SHORTCUTS.italic; + const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - // Only handle if we're in the editor - const target = e.target as HTMLElement; - const isInEditor = target.closest(".ProseMirror"); - if (isInEditor && editor) { - e.preventDefault(); - handleAddLink(); - } + const target = e.target as HTMLElement; + const isInEditor = target.closest(".ProseMirror"); + if (!isInEditor || !editor) return; + + const matchesBold = + boldShortcut && matchesParsedShortcut(e, boldShortcut); + const matchesItalic = + italicShortcut && matchesParsedShortcut(e, italicShortcut); + + if (matchesBold) { + e.preventDefault(); + e.stopPropagation(); + editor.chain().focus().toggleBold().run(); + return; + } + + if (matchesItalic) { + e.preventDefault(); + e.stopPropagation(); + editor.chain().focus().toggleItalic().run(); + return; + } + + // Block TipTap's built-in defaults when user has remapped these shortcuts. + const pressedDefaultBold = + defaultBoldShortcut && matchesParsedShortcut(e, defaultBoldShortcut); + const pressedDefaultItalic = + defaultItalicShortcut && + matchesParsedShortcut(e, defaultItalicShortcut); + + if ((boldChanged && pressedDefaultBold) || (italicChanged && pressedDefaultItalic)) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [editor, shortcuts.bold, shortcuts.italic]); + + // Keyboard shortcut for add/edit link (only when editor is focused) + useEffect(() => { + const addOrEditLinkShortcut = parseShortcut(shortcuts.addOrEditLink); + + const handleKeyDown = (e: KeyboardEvent) => { + if ( + !addOrEditLinkShortcut || + !matchesParsedShortcut(e, addOrEditLinkShortcut) + ) { + return; + } + + // Only handle if we're in the editor + const target = e.target as HTMLElement; + const isInEditor = target.closest(".ProseMirror"); + if (isInEditor && editor) { + e.preventDefault(); + handleAddLink(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [handleAddLink, editor]); + }, [editor, handleAddLink, shortcuts.addOrEditLink]); - // Keyboard shortcut for Cmd+Shift+C to open copy menu + // Keyboard shortcut for copy-as menu useEffect(() => { + const copyAsShortcut = parseShortcut(shortcuts.copyAs); + const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "c") { + if (copyAsShortcut && matchesParsedShortcut(e, copyAsShortcut)) { e.preventDefault(); setCopyMenuOpen(true); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, []); + }, [shortcuts.copyAs]); - // Cmd+F to open search (works when document/editor area is focused) + // Shortcut to open in-note search (works when document/editor area is focused) useEffect(() => { + const findInNoteShortcut = parseShortcut(shortcuts.findInNote); + const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "f") { - if (!currentNote || !editor) return; - - const target = e.target as HTMLElement; - const tagName = target.tagName.toLowerCase(); - - // Don't intercept if user is in an input/textarea (except the editor itself) - if ( - (tagName === "input" || tagName === "textarea") && - !target.closest(".ProseMirror") - ) { - return; - } + if (!findInNoteShortcut || !matchesParsedShortcut(e, findInNoteShortcut)) { + return; + } - // Don't intercept if in sidebar - if (target.closest('[class*="sidebar"]')) { - return; - } + if (!currentNote || !editor) return; - // Open search for the editor - e.preventDefault(); - setSearchOpen(true); + const target = e.target as HTMLElement; + const tagName = target.tagName.toLowerCase(); + + // Don't intercept if user is in an input/textarea (except the editor itself) + if ( + (tagName === "input" || tagName === "textarea") && + !target.closest(".ProseMirror") + ) { + return; + } + + // Don't intercept if in sidebar + if (target.closest('[class*="sidebar"]')) { + return; } + + // Open search for the editor + e.preventDefault(); + setSearchOpen(true); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [editor, currentNote]); + }, [editor, currentNote, shortcuts.findInNote]); // Clear search on note switch useEffect(() => { @@ -1197,6 +1278,15 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { } }, [editor]); + const createNoteShortcutLabel = getShortcutDisplayText(shortcuts.createNote); + const toggleSidebarShortcutLabel = getShortcutDisplayText(shortcuts.toggleSidebar); + const reloadNoteShortcutLabel = getShortcutDisplayText(shortcuts.reloadCurrentNote); + const boldShortcutLabel = getShortcutDisplayText(shortcuts.bold); + const italicShortcutLabel = getShortcutDisplayText(shortcuts.italic); + const findInNoteShortcutLabel = getShortcutDisplayText(shortcuts.findInNote); + const copyAsShortcutLabel = getShortcutDisplayText(shortcuts.copyAs); + const addOrEditLinkShortcutLabel = getShortcutDisplayText(shortcuts.addOrEditLink); + if (!currentNote) { return (
@@ -1225,10 +1315,7 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { className="mt-4" > New Note{" "} - - {mod} - {isMac ? "" : "+"}N - + {createNoteShortcutLabel}
@@ -1252,8 +1339,8 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { onClick={onToggleSidebar} title={ sidebarVisible - ? `Hide sidebar (${mod}${isMac ? "" : "+"}\\)` - : `Show sidebar (${mod}${isMac ? "" : "+"}\\)` + ? `Hide sidebar (${toggleSidebarShortcutLabel})` + : `Show sidebar (${toggleSidebarShortcutLabel})` } className="shrink-0" > @@ -1267,7 +1354,7 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) {
{hasExternalChanges ? ( ); diff --git a/src/components/settings/ShortcutsSettingsSection.tsx b/src/components/settings/ShortcutsSettingsSection.tsx index 5e4b44a..8ff81f3 100644 --- a/src/components/settings/ShortcutsSettingsSection.tsx +++ b/src/components/settings/ShortcutsSettingsSection.tsx @@ -1,95 +1,134 @@ -import { mod } from "../../lib/platform"; +import { useEffect, useState } from "react"; +import { Button } from "../ui"; +import { useTheme } from "../../context/ThemeContext"; +import type { ShortcutAction } from "../../types/note"; +import { + getShortcutKeysForDisplay, + isDefaultShortcutSet, + shortcutFromKeyboardEvent, +} from "../../lib/shortcuts"; -interface Shortcut { +type ShortcutCategory = "Navigation" | "Notes" | "Editor" | "Settings"; + +interface EditableShortcutRow { + type: "editable"; + action: ShortcutAction; + description: string; + category: ShortcutCategory; +} + +interface StaticShortcutRow { + type: "static"; keys: string[]; description: string; - category?: string; + category: ShortcutCategory; } -const shortcuts: Shortcut[] = [ +type ShortcutRow = EditableShortcutRow | StaticShortcutRow; + +const shortcutRows: ShortcutRow[] = [ { - keys: [mod, "P"], + type: "editable", + action: "openCommandPalette", description: "Open command palette", category: "Navigation", }, { - keys: [mod, "N"], - description: "Create new note", - category: "Notes", + type: "editable", + action: "openSettings", + description: "Open settings", + category: "Navigation", }, { - keys: [mod, "R"], - description: "Reload current note", - category: "Notes", + type: "editable", + action: "toggleSidebar", + description: "Toggle sidebar", + category: "Navigation", }, { - keys: [mod, ","], - description: "Open settings", + type: "editable", + action: "navigateNoteUp", + description: "Navigate note list up", category: "Navigation", }, { - keys: [mod, "\\"], - description: "Toggle sidebar", + type: "editable", + action: "navigateNoteDown", + description: "Navigate note list down", category: "Navigation", }, { - keys: [mod, "K"], + type: "editable", + action: "createNote", + description: "Create new note", + category: "Notes", + }, + { + type: "editable", + action: "reloadCurrentNote", + description: "Reload current note", + category: "Notes", + }, + { + type: "editable", + action: "addOrEditLink", description: "Add or edit link", category: "Editor", }, { - keys: [mod, "B"], + type: "editable", + action: "bold", description: "Bold", category: "Editor", }, { - keys: [mod, "I"], + type: "editable", + action: "italic", description: "Italic", category: "Editor", }, { - keys: [mod, "Shift", "C"], + type: "editable", + action: "copyAs", description: "Copy as (Markdown/Plain Text/HTML)", category: "Editor", }, { - keys: [mod, "F"], + type: "editable", + action: "findInNote", description: "Find in current note", category: "Editor", }, { - keys: ["↑", "↓"], - description: "Navigate note list", - category: "Navigation", - }, - { - keys: [mod, "1"], + type: "editable", + action: "settingsGeneralTab", description: "Go to General settings", category: "Settings", }, { - keys: [mod, "2"], + type: "editable", + action: "settingsAppearanceTab", description: "Go to Appearance settings", category: "Settings", }, { - keys: [mod, "3"], + type: "editable", + action: "settingsShortcutsTab", description: "Go to Shortcuts settings", category: "Settings", }, ]; // Group shortcuts by category -const groupedShortcuts = shortcuts.reduce( +const groupedShortcuts = shortcutRows.reduce( (acc, shortcut) => { - const category = shortcut.category || "General"; - if (!acc[category]) { - acc[category] = []; + if (!acc[shortcut.category]) { + acc[shortcut.category] = []; } - acc[category].push(shortcut); + acc[shortcut.category].push(shortcut); return acc; }, - {} as Record, + {} as Record, ); // Render individual key as keyboard button @@ -105,41 +144,143 @@ function KeyboardKey({ keyLabel }: { keyLabel: string }) { function ShortcutKeys({ keys }: { keys: string[] }) { return (
- {keys.map((key, index) => ( - + {keys.map((key) => ( + ))}
); } export function ShortcutsSettingsSection() { - const categoryOrder = ["Navigation", "Notes", "Editor", "Settings"]; + const { shortcuts, setShortcut, resetShortcuts } = useTheme(); + const [capturingAction, setCapturingAction] = useState( + null, + ); + const [captureError, setCaptureError] = useState(null); + const categoryOrder: ShortcutCategory[] = [ + "Navigation", + "Notes", + "Editor", + "Settings", + ]; + const hasCustomShortcuts = !isDefaultShortcutSet(shortcuts); + + // Capture keys globally while recording so Escape works even if focus changes. + useEffect(() => { + if (!capturingAction) return; + + const handleCaptureKeyDown = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if ( + event.key === "Escape" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + setCapturingAction(null); + setCaptureError(null); + return; + } + + const nextShortcut = shortcutFromKeyboardEvent(event); + if (!nextShortcut) return; + + const saved = setShortcut(capturingAction, nextShortcut); + if (saved) { + setCapturingAction(null); + setCaptureError(null); + } else { + setCaptureError( + "This shortcut must include Cmd/Ctrl. Note navigation can be plain keys.", + ); + } + }; + + window.addEventListener("keydown", handleCaptureKeyDown, true); + return () => { + window.removeEventListener("keydown", handleCaptureKeyDown, true); + }; + }, [capturingAction, setShortcut]); return (
+
+
+

Keyboard Shortcuts

+ {hasCustomShortcuts && ( + + )} +
+

+ Click a shortcut and press your preferred key combination. Press + Escape to cancel. Most shortcuts require Cmd/Ctrl, except note + navigation. +

+ {captureError &&

{captureError}

} +
+ {categoryOrder.map((category, idx) => { const categoryShortcuts = groupedShortcuts[category]; if (!categoryShortcuts) return null; return (
- {idx > 0 && ( -
- )} + {idx > 0 &&
}
-

{category}

+

{category}

- {categoryShortcuts.map((shortcut, index) => ( -
- - {shortcut.description} - - -
- ))} + {categoryShortcuts.map((shortcut) => { + if (shortcut.type === "editable") { + const isCapturing = capturingAction === shortcut.action; + const keys = getShortcutKeysForDisplay( + shortcuts[shortcut.action], + ); + + return ( +
+ + {shortcut.description} + + +
+ ); + } + + return ( +
+ + {shortcut.description} + + +
+ ); + })}
diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 47f8234..d6fdd39 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -11,7 +11,15 @@ import type { ThemeSettings, EditorFontSettings, FontFamily, + ShortcutAction, } from "../types/note"; +import { + DEFAULT_SHORTCUTS, + buildShortcutOverrides, + normalizeShortcut, + parseShortcut, + resolveShortcutSettings, +} from "../lib/shortcuts"; type ThemeMode = "light" | "dark" | "system"; @@ -37,6 +45,9 @@ interface ThemeContextType { resolvedTheme: "light" | "dark"; setTheme: (theme: ThemeMode) => void; cycleTheme: () => void; + shortcuts: Record; + setShortcut: (action: ShortcutAction, shortcut: string) => boolean; + resetShortcuts: () => void; editorFontSettings: Required; setEditorFontSetting: ( key: K, @@ -88,6 +99,9 @@ function applyFontCSSVariables(fonts: Required) { export function ThemeProvider({ children }: ThemeProviderProps) { const [theme, setThemeState] = useState("system"); + const [shortcuts, setShortcuts] = useState>( + DEFAULT_SHORTCUTS + ); const [editorFontSettings, setEditorFontSettings] = useState< Required >(defaultEditorFontSettings); @@ -119,6 +133,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { ...fontSettings, }); } + setShortcuts(resolveShortcutSettings(settings.shortcuts)); } catch { // If settings can't be loaded, use defaults } @@ -226,6 +241,50 @@ export function ThemeProvider({ children }: ThemeProviderProps) { [saveFontSettings] ); + // Save shortcut settings to backend + const saveShortcutSettings = useCallback( + async (newShortcuts: Record) => { + try { + const settings = await getSettings(); + await updateSettings({ + ...settings, + shortcuts: buildShortcutOverrides(newShortcuts), + }); + } catch (error) { + console.error("Failed to save shortcut settings:", error); + } + }, + [] + ); + + const setShortcut = useCallback( + (action: ShortcutAction, shortcut: string) => { + const normalized = normalizeShortcut(shortcut); + if (!normalized) return false; + const parsed = parseShortcut(normalized); + if (!parsed) return false; + + const allowNoModifier = + action === "navigateNoteUp" || action === "navigateNoteDown"; + if (!allowNoModifier && !parsed.mod) return false; + + setShortcuts((prev) => { + const updated = { ...prev, [action]: normalized }; + saveShortcutSettings(updated); + return updated; + }); + + return true; + }, + [saveShortcutSettings] + ); + + const resetShortcuts = useCallback(() => { + const defaults = { ...DEFAULT_SHORTCUTS }; + setShortcuts(defaults); + saveShortcutSettings(defaults); + }, [saveShortcutSettings]); + // Reset font settings to defaults const resetEditorFontSettings = useCallback(() => { setEditorFontSettings(defaultEditorFontSettings); @@ -244,6 +303,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) { resolvedTheme, setTheme, cycleTheme, + shortcuts, + setShortcut, + resetShortcuts, editorFontSettings, setEditorFontSetting, resetEditorFontSettings, diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 0000000..a5ba323 --- /dev/null +++ b/src/lib/shortcuts.ts @@ -0,0 +1,251 @@ +import type { ShortcutAction, ShortcutSettings } from "../types/note"; +import { alt, mod, shift } from "./platform"; + +export interface ParsedShortcut { + key: string; + mod: boolean; + alt: boolean; + shift: boolean; +} + +export const DEFAULT_SHORTCUTS: Record = { + openCommandPalette: "Mod+P", + createNote: "Mod+N", + reloadCurrentNote: "Mod+R", + openSettings: "Mod+,", + toggleSidebar: "Mod+\\", + navigateNoteUp: "ArrowUp", + navigateNoteDown: "ArrowDown", + addOrEditLink: "Mod+K", + bold: "Mod+B", + italic: "Mod+I", + copyAs: "Mod+Shift+C", + findInNote: "Mod+F", + settingsGeneralTab: "Mod+1", + settingsAppearanceTab: "Mod+2", + settingsShortcutsTab: "Mod+3", +}; + +const modifierAliases: Record> = { + mod: "mod", + cmd: "mod", + command: "mod", + ctrl: "mod", + control: "mod", + meta: "mod", + alt: "alt", + option: "alt", + shift: "shift", +}; + +const namedKeyAliases: Record = { + comma: ",", + period: ".", + dot: ".", + slash: "/", + backslash: "\\", + space: "Space", + spacebar: "Space", + esc: "Escape", + escape: "Escape", + enter: "Enter", + return: "Enter", + tab: "Tab", +}; + +const modifierKeys = new Set(["Meta", "Control", "Alt", "Shift"]); + +function normalizeKeyToken(token: string): string | null { + const trimmed = token.trim(); + if (!trimmed) return null; + + const lower = trimmed.toLowerCase(); + if (namedKeyAliases[lower]) { + return namedKeyAliases[lower]; + } + + if (trimmed.length === 1) { + const char = trimmed; + if (/[a-z]/i.test(char)) { + return char.toUpperCase(); + } + return char; + } + + if (lower.startsWith("arrow")) { + return `Arrow${lower.slice(5, 6).toUpperCase()}${lower.slice(6)}`; + } + + return `${trimmed.slice(0, 1).toUpperCase()}${trimmed.slice(1)}`; +} + +function normalizeEventKey(key: string): string | null { + if (!key) return null; + + if (key === " ") return "Space"; + if (key === "Esc") return "Escape"; + + if (key.length === 1) { + if (/[a-z]/i.test(key)) { + return key.toUpperCase(); + } + return key; + } + + return normalizeKeyToken(key); +} + +function serializeShortcut(shortcut: ParsedShortcut): string { + const parts: string[] = []; + if (shortcut.mod) parts.push("Mod"); + if (shortcut.alt) parts.push("Alt"); + if (shortcut.shift) parts.push("Shift"); + parts.push(shortcut.key); + return parts.join("+"); +} + +export function parseShortcut(shortcut: string): ParsedShortcut | null { + const tokens = shortcut + .split("+") + .map((part) => part.trim()) + .filter(Boolean); + + if (tokens.length === 0) return null; + + const parsed: ParsedShortcut = { + key: "", + mod: false, + alt: false, + shift: false, + }; + + for (const token of tokens) { + const lower = token.toLowerCase(); + const modifier = modifierAliases[lower]; + if (modifier) { + parsed[modifier] = true; + continue; + } + + if (parsed.key) { + return null; + } + + const normalizedKey = normalizeKeyToken(token); + if (!normalizedKey) return null; + parsed.key = normalizedKey; + } + + return parsed.key ? parsed : null; +} + +export function normalizeShortcut(shortcut: string): string | null { + const parsed = parseShortcut(shortcut); + if (!parsed) return null; + return serializeShortcut(parsed); +} + +export function shortcutFromKeyboardEvent(event: KeyboardEvent): string | null { + if (modifierKeys.has(event.key)) { + return null; + } + + const key = normalizeEventKey(event.key); + if (!key) return null; + + return serializeShortcut({ + key, + mod: event.metaKey || event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + }); +} + +export function matchesParsedShortcut( + event: KeyboardEvent, + parsed: ParsedShortcut, +): boolean { + const eventKey = normalizeEventKey(event.key); + if (!eventKey) return false; + + const modPressed = event.metaKey || event.ctrlKey; + if (parsed.mod !== modPressed) return false; + if (parsed.alt !== event.altKey) return false; + if (parsed.shift !== event.shiftKey) return false; + + return parsed.key === eventKey; +} + +export function matchesShortcut(event: KeyboardEvent, shortcut: string): boolean { + const parsed = parseShortcut(shortcut); + if (!parsed) return false; + return matchesParsedShortcut(event, parsed); +} + +export function resolveShortcutSettings( + shortcutSettings?: ShortcutSettings, +): Record { + const resolved = { ...DEFAULT_SHORTCUTS }; + + if (!shortcutSettings) { + return resolved; + } + + for (const action of Object.keys(DEFAULT_SHORTCUTS) as ShortcutAction[]) { + const value = shortcutSettings[action]; + if (!value) continue; + const normalized = normalizeShortcut(value); + if (normalized) { + resolved[action] = normalized; + } + } + + return resolved; +} + +export function buildShortcutOverrides( + shortcuts: Record, +): ShortcutSettings { + const overrides: ShortcutSettings = {}; + + for (const action of Object.keys(DEFAULT_SHORTCUTS) as ShortcutAction[]) { + const normalized = normalizeShortcut(shortcuts[action]); + if (!normalized) continue; + if (normalized !== DEFAULT_SHORTCUTS[action]) { + overrides[action] = normalized; + } + } + + return overrides; +} + +export function isDefaultShortcutSet( + shortcuts: Record, +): boolean { + return (Object.keys(DEFAULT_SHORTCUTS) as ShortcutAction[]).every((action) => { + const normalized = normalizeShortcut(shortcuts[action]); + return normalized === DEFAULT_SHORTCUTS[action]; + }); +} + +export function getShortcutKeysForDisplay(shortcut: string): string[] { + const parsed = parseShortcut(shortcut); + if (!parsed) return [shortcut]; + + const displayKey = + parsed.key === "ArrowUp" + ? "↑" + : parsed.key === "ArrowDown" + ? "↓" + : parsed.key; + const keys: string[] = []; + if (parsed.mod) keys.push(mod); + if (parsed.alt) keys.push(alt); + if (parsed.shift) keys.push(shift); + keys.push(displayKey); + return keys; +} + +export function getShortcutDisplayText(shortcut: string): string { + return getShortcutKeysForDisplay(shortcut).join(" "); +} diff --git a/src/types/note.ts b/src/types/note.ts index a0e0b45..ceac793 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -19,6 +19,25 @@ export interface ThemeSettings { export type FontFamily = "system-sans" | "serif" | "monospace"; +export type ShortcutAction = + | "openCommandPalette" + | "createNote" + | "reloadCurrentNote" + | "openSettings" + | "toggleSidebar" + | "navigateNoteUp" + | "navigateNoteDown" + | "addOrEditLink" + | "bold" + | "italic" + | "copyAs" + | "findInNote" + | "settingsGeneralTab" + | "settingsAppearanceTab" + | "settingsShortcutsTab"; + +export type ShortcutSettings = Partial>; + export interface EditorFontSettings { baseFontFamily?: FontFamily; baseFontSize?: number; // in px, default 16 @@ -32,4 +51,5 @@ export interface Settings { editorFont?: EditorFontSettings; gitEnabled?: boolean; pinnedNoteIds?: string[]; + shortcuts?: ShortcutSettings; } From 237c34d98c9b6c8a23f00b7ef95f75b5e6c5ba0a Mon Sep 17 00:00:00 2001 From: "Timo S." Date: Thu, 12 Feb 2026 12:19:56 +0100 Subject: [PATCH 2/4] Always On Top Feature --- src-tauri/src/lib.rs | 25 +++++++++++++++++++ src/App.tsx | 24 ++++++++++++++++++ .../command-palette/CommandPalette.tsx | 21 ++++++++++++++++ .../settings/ShortcutsSettingsSection.tsx | 6 +++++ src/lib/shortcuts.ts | 1 + src/services/notes.ts | 4 +++ src/types/note.ts | 1 + 7 files changed, 82 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6870dbd..fd25d78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1352,6 +1352,30 @@ fn rebuild_search_index(app: AppHandle, state: State) -> Result<(), St Ok(()) } +#[tauri::command] +fn toggle_always_on_top(app: AppHandle) -> Result { + let window = app + .get_webview_window("main") + .ok_or_else(|| "Main window not found".to_string())?; + + let is_always_on_top = window.is_always_on_top().map_err(|e| e.to_string())?; + let next_state = !is_always_on_top; + + window + .set_always_on_top(next_state) + .map_err(|e| e.to_string())?; + + if next_state { + if let Ok(true) = window.is_minimized() { + let _ = window.unminimize(); + } + let _ = window.show(); + let _ = window.set_focus(); + } + + Ok(next_state) +} + // UI helper commands - wrap Tauri plugins for consistent invoke-based API #[tauri::command] @@ -1956,6 +1980,7 @@ pub fn run() { search_notes, start_file_watcher, rebuild_search_index, + toggle_always_on_top, copy_to_clipboard, copy_image_to_assets, save_clipboard_image, diff --git a/src/App.tsx b/src/App.tsx index ac57053..9fe5372 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { } from "@tauri-apps/plugin-updater"; import { listen } from "@tauri-apps/api/event"; import * as aiService from "./services/ai"; +import * as notesService from "./services/notes"; import { matchesParsedShortcut, parseShortcut } from "./lib/shortcuts"; type ViewState = "notes" | "settings"; @@ -46,6 +47,18 @@ function AppContent() { setSidebarVisible((prev) => !prev); }, []); + const toggleAlwaysOnTop = useCallback(async () => { + try { + const isAlwaysOnTop = await notesService.toggleAlwaysOnTop(); + toast.success( + isAlwaysOnTop ? "Always on top enabled" : "Always on top disabled", + ); + } catch (error) { + console.error("Failed to toggle always on top:", error); + toast.error("Failed to toggle always on top"); + } + }, []); + const toggleSettings = useCallback(() => { setView((prev) => (prev === "settings" ? "notes" : "settings")); }, []); @@ -139,6 +152,7 @@ function AppContent() { const openSettingsShortcut = parseShortcut(shortcuts.openSettings); const commandPaletteShortcut = parseShortcut(shortcuts.openCommandPalette); const toggleSidebarShortcut = parseShortcut(shortcuts.toggleSidebar); + const toggleAlwaysOnTopShortcut = parseShortcut(shortcuts.toggleAlwaysOnTop); const createNoteShortcut = parseShortcut(shortcuts.createNote); const reloadCurrentNoteShortcut = parseShortcut(shortcuts.reloadCurrentNote); const navigateNoteUpShortcut = parseShortcut(shortcuts.navigateNoteUp); @@ -160,6 +174,15 @@ function AppContent() { return; } + if ( + toggleAlwaysOnTopShortcut && + matchesParsedShortcut(e, toggleAlwaysOnTopShortcut) + ) { + e.preventDefault(); + void toggleAlwaysOnTop(); + return; + } + // Block all other shortcuts when in settings view if (view === "settings") { return; @@ -281,6 +304,7 @@ function AppContent() { selectNote, toggleSettings, toggleSidebar, + toggleAlwaysOnTop, view, shortcuts, ]); diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index a87f0af..bfc9d60 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -104,6 +104,26 @@ export function CommandPalette({ onClose(); }, }, + { + id: "toggle-always-on-top", + label: "Toggle Always on Top", + shortcut: getShortcutDisplayText(shortcuts.toggleAlwaysOnTop), + icon: , + action: async () => { + try { + const isAlwaysOnTop = await notesService.toggleAlwaysOnTop(); + toast.success( + isAlwaysOnTop + ? "Always on top enabled" + : "Always on top disabled", + ); + onClose(); + } catch (error) { + console.error("Failed to toggle always on top:", error); + toast.error("Failed to toggle always on top"); + } + }, + }, ]; // Add note-specific commands if a note is selected @@ -310,6 +330,7 @@ export function CommandPalette({ pinNote, unpinNote, shortcuts.createNote, + shortcuts.toggleAlwaysOnTop, shortcuts.openSettings, ]); diff --git a/src/components/settings/ShortcutsSettingsSection.tsx b/src/components/settings/ShortcutsSettingsSection.tsx index 8ff81f3..25924ca 100644 --- a/src/components/settings/ShortcutsSettingsSection.tsx +++ b/src/components/settings/ShortcutsSettingsSection.tsx @@ -45,6 +45,12 @@ const shortcutRows: ShortcutRow[] = [ description: "Toggle sidebar", category: "Navigation", }, + { + type: "editable", + action: "toggleAlwaysOnTop", + description: "Toggle always on top", + category: "Navigation", + }, { type: "editable", action: "navigateNoteUp", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index a5ba323..7b5bd7a 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -12,6 +12,7 @@ export const DEFAULT_SHORTCUTS: Record = { openCommandPalette: "Mod+P", createNote: "Mod+N", reloadCurrentNote: "Mod+R", + toggleAlwaysOnTop: "Mod+Shift+T", openSettings: "Mod+,", toggleSidebar: "Mod+\\", navigateNoteUp: "ArrowUp", diff --git a/src/services/notes.ts b/src/services/notes.ts index e5a9823..b926fd4 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -61,3 +61,7 @@ export async function searchNotes(query: string): Promise { export async function startFileWatcher(): Promise { return invoke("start_file_watcher"); } + +export async function toggleAlwaysOnTop(): Promise { + return invoke("toggle_always_on_top"); +} diff --git a/src/types/note.ts b/src/types/note.ts index ceac793..5092aa6 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -23,6 +23,7 @@ export type ShortcutAction = | "openCommandPalette" | "createNote" | "reloadCurrentNote" + | "toggleAlwaysOnTop" | "openSettings" | "toggleSidebar" | "navigateNoteUp" From 3990f89d259f9b1f08ba8e38802c478ab2374329 Mon Sep 17 00:00:00 2001 From: "Timo S." Date: Thu, 12 Feb 2026 13:57:35 +0100 Subject: [PATCH 3/4] minimal always on top scratchpad --- src-tauri/Cargo.lock | 40 +++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 2 +- src-tauri/src/lib.rs | 313 +++++++++++++++++- src/App.css | 13 + src/App.tsx | 170 +++++++++- .../command-palette/CommandPalette.tsx | 15 + src/components/editor/Editor.tsx | 204 +++++++----- .../settings/ShortcutsSettingsSection.tsx | 13 +- src/context/ThemeContext.tsx | 11 +- src/lib/shortcuts.ts | 4 +- src/services/notes.ts | 12 + src/types/note.ts | 2 + 13 files changed, 701 insertions(+), 99 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 93f125c..cb7de13 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-updater", "tokio", @@ -1515,6 +1516,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -4612,6 +4631,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -6242,6 +6276,12 @@ dependencies = [ "rustix 1.1.3", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f432736..a83e5b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-plugin-fs = "2" tauri-plugin-dialog = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-updater = "2" +tauri-plugin-global-shortcut = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 539ea21..68d54f6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": ["main", "minimal"], "permissions": [ "core:default", "core:window:allow-start-dragging", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd25d78..e7e0dbf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,8 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_clipboard_manager::ClipboardExt; +#[cfg(desktop)] +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; use tokio::fs; mod git; @@ -100,6 +102,8 @@ pub struct Settings { #[serde(rename = "pinnedNoteIds")] pub pinned_note_ids: Option>, pub shortcuts: Option>, + #[serde(rename = "lastSelectedScratchpadId")] + pub last_selected_scratchpad_id: Option, } // Search result @@ -301,6 +305,7 @@ pub struct AppState { pub file_watcher: Mutex>, pub search_index: Mutex>, pub debounce_map: Arc>>, + pub minimal_toggle_in_progress: Mutex, } impl Default for AppState { @@ -312,6 +317,7 @@ impl Default for AppState { file_watcher: Mutex::new(None), search_index: Mutex::new(None), debounce_map: Arc::new(Mutex::new(HashMap::new())), + minimal_toggle_in_progress: Mutex::new(false), } } } @@ -575,6 +581,91 @@ fn cleanup_debounce_map(map: &Mutex>) { map.retain(|_, last| now.duration_since(*last) < Duration::from_secs(5)); } +const DEFAULT_MINIMAL_SHORTCUT: &str = "Mod+Shift+M"; + +fn get_minimal_shortcut_setting(settings: &Settings) -> String { + settings + .shortcuts + .as_ref() + .and_then(|shortcuts| shortcuts.get("openMinimalEditor").cloned()) + .unwrap_or_else(|| DEFAULT_MINIMAL_SHORTCUT.to_string()) +} + +#[cfg(desktop)] +fn convert_to_global_shortcut(shortcut: &str) -> Option { + let tokens: Vec<&str> = shortcut + .split('+') + .map(|token| token.trim()) + .filter(|token| !token.is_empty()) + .collect(); + + if tokens.is_empty() { + return None; + } + + let mut converted: Vec = Vec::with_capacity(tokens.len()); + + for token in tokens { + let lower = token.to_lowercase(); + let mapped = match lower.as_str() { + "mod" | "cmd" | "command" => { + if cfg!(target_os = "macos") { + "command".to_string() + } else { + "ctrl".to_string() + } + } + "ctrl" | "control" => "ctrl".to_string(), + "alt" | "option" => "alt".to_string(), + "shift" => "shift".to_string(), + "space" | "spacebar" => "space".to_string(), + "esc" | "escape" => "escape".to_string(), + "," | "comma" => "comma".to_string(), + "." | "period" | "dot" => "period".to_string(), + "/" | "slash" => "slash".to_string(), + "\\" | "backslash" => "backslash".to_string(), + _ if lower.len() == 1 => lower, + _ if lower.starts_with("arrow") => lower.replace("arrow", ""), + _ => lower, + }; + converted.push(mapped); + } + + Some(converted.join("+")) +} + +#[cfg(desktop)] +fn register_global_minimal_shortcut(app: &AppHandle, settings: &Settings) -> Result<(), String> { + let global_shortcut = app.global_shortcut(); + global_shortcut + .unregister_all() + .map_err(|e| e.to_string())?; + + let configured = get_minimal_shortcut_setting(settings); + let configured_shortcut = convert_to_global_shortcut(&configured) + .ok_or_else(|| "Invalid minimal shortcut format".to_string())?; + + if global_shortcut + .register(configured_shortcut.as_str()) + .is_ok() + { + return Ok(()); + } + + let fallback = convert_to_global_shortcut(DEFAULT_MINIMAL_SHORTCUT) + .ok_or_else(|| "Invalid fallback shortcut format".to_string())?; + global_shortcut + .register(fallback.as_str()) + .map_err(|e| format!("Failed to register global shortcut: {}", e))?; + + Ok(()) +} + +#[cfg(not(desktop))] +fn register_global_minimal_shortcut(_app: &AppHandle, _settings: &Settings) -> Result<(), String> { + Ok(()) +} + // TAURI COMMANDS #[tauri::command] @@ -616,7 +707,7 @@ fn set_notes_folder(app: AppHandle, path: String, state: State) -> Res // Update settings in memory { let mut current_settings = state.settings.write().expect("settings write lock"); - *current_settings = settings; + *current_settings = settings.clone(); } // Save app config to disk @@ -634,6 +725,10 @@ fn set_notes_folder(app: AppHandle, path: String, state: State) -> Res } } + if let Err(err) = register_global_minimal_shortcut(&app, &settings) { + eprintln!("Failed to register global minimal shortcut: {}", err); + } + Ok(()) } @@ -850,6 +945,19 @@ async fn save_note( cache.remove(old_id_str); } + let mut settings_changed = false; + if let Some((ref old_id_str, _)) = old_id { + let mut settings = state.settings.write().expect("settings write lock"); + if settings.last_selected_scratchpad_id.as_deref() == Some(old_id_str) { + settings.last_selected_scratchpad_id = Some(final_id.clone()); + settings_changed = true; + } + } + if settings_changed { + let settings = state.settings.read().expect("settings read lock"); + save_settings(&folder, &settings).map_err(|e| e.to_string())?; + } + Ok(Note { id: final_id, title, @@ -890,11 +998,23 @@ async fn delete_note(id: String, state: State<'_, AppState>) -> Result<(), Strin cache.remove(&id); } + let mut settings_changed = false; + { + let mut settings = state.settings.write().expect("settings write lock"); + if settings.last_selected_scratchpad_id.as_deref() == Some(&id) { + settings.last_selected_scratchpad_id = None; + settings_changed = true; + } + } + if settings_changed { + let settings = state.settings.read().expect("settings read lock"); + save_settings(&folder, &settings).map_err(|e| e.to_string())?; + } + Ok(()) } -#[tauri::command] -async fn create_note(state: State<'_, AppState>) -> Result { +async fn create_note_internal(state: &State<'_, AppState>) -> Result { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); app_config @@ -943,13 +1063,83 @@ async fn create_note(state: State<'_, AppState>) -> Result { }) } +#[tauri::command] +async fn create_note(state: State<'_, AppState>) -> Result { + create_note_internal(&state).await +} + +async fn get_or_create_minimal_scratchpad_id( + state: &State<'_, AppState>, +) -> Result { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + + let folder_path = PathBuf::from(&folder); + let existing_id = { + let settings = state.settings.read().expect("settings read lock"); + settings.last_selected_scratchpad_id.clone() + }; + + if let Some(id) = existing_id { + let note_path = folder_path.join(format!("{}.md", id)); + if note_path.exists() { + return Ok(id); + } + } + + let note = create_note_internal(state).await?; + { + let mut settings = state.settings.write().expect("settings write lock"); + settings.last_selected_scratchpad_id = Some(note.id.clone()); + } + let settings = state.settings.read().expect("settings read lock"); + save_settings(&folder, &settings).map_err(|e| e.to_string())?; + + Ok(note.id) +} + +#[tauri::command] +async fn get_or_create_minimal_scratchpad(state: State<'_, AppState>) -> Result { + get_or_create_minimal_scratchpad_id(&state).await +} + +#[tauri::command] +fn set_last_selected_scratchpad(id: Option, state: State) -> Result<(), String> { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + + { + let mut settings = state.settings.write().expect("settings write lock"); + settings.last_selected_scratchpad_id = id; + } + + let settings = state.settings.read().expect("settings read lock"); + save_settings(&folder, &settings).map_err(|e| e.to_string())?; + + Ok(()) +} + #[tauri::command] fn get_settings(state: State) -> Settings { state.settings.read().expect("settings read lock").clone() } #[tauri::command] -fn update_settings(new_settings: Settings, state: State) -> Result<(), String> { +fn update_settings( + app: AppHandle, + new_settings: Settings, + state: State, +) -> Result<(), String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); app_config @@ -965,6 +1155,9 @@ fn update_settings(new_settings: Settings, state: State) -> Result<(), let settings = state.settings.read().expect("settings read lock"); save_settings(&folder, &settings).map_err(|e| e.to_string())?; + if let Err(err) = register_global_minimal_shortcut(&app, &settings) { + eprintln!("Failed to register global minimal shortcut: {}", err); + } Ok(()) } @@ -1376,6 +1569,99 @@ fn toggle_always_on_top(app: AppHandle) -> Result { Ok(next_state) } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MinimalEditorOpenEvent { + note_id: String, +} + +#[tauri::command] +async fn toggle_minimal_editor(app: AppHandle, state: State<'_, AppState>) -> Result { + { + let mut in_progress = state + .minimal_toggle_in_progress + .lock() + .expect("minimal toggle mutex"); + if *in_progress { + return Ok(app.get_webview_window("minimal").is_some()); + } + *in_progress = true; + } + + let result = async { + if let Some(existing_window) = app.get_webview_window("minimal") { + let is_visible = existing_window.is_visible().unwrap_or(false); + let is_minimized = existing_window.is_minimized().unwrap_or(false); + + // If already shown, toggle it off. + if is_visible && !is_minimized { + existing_window.hide().map_err(|e| e.to_string())?; + return Ok(false); + } + } + + let note_id = get_or_create_minimal_scratchpad_id(&state).await?; + + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("mode", "minimal") + .append_pair("noteId", ¬e_id) + .finish(); + let url = format!("index.html?{}", query); + + let window = if let Some(existing_window) = app.get_webview_window("minimal") { + existing_window + } else { + let mut builder = + tauri::WebviewWindowBuilder::new(&app, "minimal", tauri::WebviewUrl::App(url.into())) + .title("Scratchpad") + .inner_size(520.0, 420.0) + .min_inner_size(360.0, 240.0) + .decorations(true) + .always_on_top(true) + .visible_on_all_workspaces(true) + .skip_taskbar(true); + + #[cfg(target_os = "macos")] + { + builder = builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true) + .traffic_light_position(tauri::Position::Logical( + tauri::LogicalPosition::new(16.0, 22.0), + )); + } + + builder.build().map_err(|e| e.to_string())? + }; + + if let Ok(true) = window.is_minimized() { + let _ = window.unminimize(); + } + let _ = window.set_always_on_top(true); + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.emit( + "minimal-open-note", + MinimalEditorOpenEvent { + note_id: note_id.clone(), + }, + ); + + Ok(true) + } + .await; + + { + let mut in_progress = state + .minimal_toggle_in_progress + .lock() + .expect("minimal toggle mutex"); + *in_progress = false; + } + + result +} + // UI helper commands - wrap Tauri plugins for consistent invoke-based API #[tauri::command] @@ -1875,6 +2161,15 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state() == ShortcutState::Pressed { + let _ = app.emit("global-toggle-minimal-editor", ()); + } + }) + .build(), + ) .on_window_event(|window, event| { #[cfg(target_os = "macos")] if window.label() == "main" @@ -1963,8 +2258,15 @@ pub fn run() { file_watcher: Mutex::new(None), search_index: Mutex::new(search_index), debounce_map: Arc::new(Mutex::new(HashMap::new())), + minimal_toggle_in_progress: Mutex::new(false), }; app.manage(state); + if let Some(state) = app.try_state::() { + let settings = state.settings.read().expect("settings read lock").clone(); + if let Err(err) = register_global_minimal_shortcut(app.handle(), &settings) { + eprintln!("Failed to register global minimal shortcut: {}", err); + } + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -1975,12 +2277,15 @@ pub fn run() { save_note, delete_note, create_note, + get_or_create_minimal_scratchpad, + set_last_selected_scratchpad, get_settings, update_settings, search_notes, start_file_watcher, rebuild_search_index, toggle_always_on_top, + toggle_minimal_editor, copy_to_clipboard, copy_image_to_assets, save_clipboard_image, diff --git a/src/App.css b/src/App.css index 04d8c47..abbf4ce 100644 --- a/src/App.css +++ b/src/App.css @@ -113,6 +113,19 @@ body { -moz-osx-font-smoothing: grayscale; } +.minimal-scratchpad-shell { + border-radius: 1.1rem; + border: 1px solid var(--color-border); + background: var(--color-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +html.dark .minimal-scratchpad-shell { + border: 1px solid var(--color-border); + background: var(--color-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + /* Text and image selection highlight */ ::selection { background-color: var(--color-selection); diff --git a/src/App.tsx b/src/App.tsx index 9fe5372..b64bb83 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { toast } from "sonner"; import { NotesProvider, useNotes } from "./context/NotesContext"; import { ThemeProvider, useTheme } from "./context/ThemeContext"; @@ -17,13 +17,14 @@ import { type Update, } from "@tauri-apps/plugin-updater"; import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import * as aiService from "./services/ai"; import * as notesService from "./services/notes"; import { matchesParsedShortcut, parseShortcut } from "./lib/shortcuts"; type ViewState = "notes" | "settings"; -function AppContent() { +function AppContent({ minimalMode }: { minimalMode: boolean }) { const { notesFolder, isLoading, @@ -42,6 +43,8 @@ function AppContent() { const [sidebarVisible, setSidebarVisible] = useState(true); const [aiModalOpen, setAiModalOpen] = useState(false); const [aiEditing, setAiEditing] = useState(false); + const minimalToggleInFlightRef = useRef(false); + const lastMinimalToggleAtRef = useRef(0); const toggleSidebar = useCallback(() => { setSidebarVisible((prev) => !prev); @@ -63,26 +66,148 @@ function AppContent() { setView((prev) => (prev === "settings" ? "notes" : "settings")); }, []); + const toggleMinimalEditor = useCallback(async () => { + const now = Date.now(); + // Ignore rapid duplicate triggers (shortcut key-repeat, StrictMode listener races). + if ( + minimalToggleInFlightRef.current || + now - lastMinimalToggleAtRef.current < 250 + ) { + return; + } + + minimalToggleInFlightRef.current = true; + lastMinimalToggleAtRef.current = now; + + try { + await notesService.toggleMinimalEditor(); + } catch (error) { + console.error("Failed to toggle minimal editor:", error); + toast.error("Failed to toggle minimal scratchpad"); + } finally { + minimalToggleInFlightRef.current = false; + } + }, []); + const closeSettings = useCallback(() => { setView("notes"); }, []); // Open settings when requested by the macOS tray menu useEffect(() => { - let unlisten: (() => void) | null = null; + if (minimalMode) return; + + let cancelled = false; + let unlisten: (() => void) | undefined; + + listen("tray-open-settings", () => { + setView("settings"); + }).then((fn) => { + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + }); - const setup = async () => { - unlisten = await listen("tray-open-settings", () => { - setView("settings"); - }); + return () => { + cancelled = true; + if (unlisten) { + unlisten(); + } }; + }, [minimalMode]); - setup(); + // Handle OS-level global shortcut events from backend. + useEffect(() => { + if (minimalMode) return; + + let cancelled = false; + let unlisten: (() => void) | undefined; + + listen("global-toggle-minimal-editor", () => { + void toggleMinimalEditor(); + }).then((fn) => { + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + }); return () => { - if (unlisten) unlisten(); + cancelled = true; + if (unlisten) { + unlisten(); + } }; - }, []); + }, [minimalMode, toggleMinimalEditor]); + + // Persist the last selected scratchpad so minimal mode can restore it. + useEffect(() => { + if (minimalMode || !notesFolder || !selectedNoteId) return; + notesService.setLastSelectedScratchpad(selectedNoteId).catch((error) => { + console.error("Failed to persist last selected scratchpad:", error); + }); + }, [minimalMode, notesFolder, selectedNoteId]); + + // Minimal mode startup: always load the last selected scratchpad (or create one). + useEffect(() => { + if (!minimalMode || isLoading || !notesFolder) return; + + let cancelled = false; + let unlisten: (() => void) | undefined; + + const openScratchpad = async (preferredNoteId?: string | null) => { + if (cancelled) return; + + if (preferredNoteId) { + try { + await notesService.readNote(preferredNoteId); + if (!cancelled) { + await selectNote(preferredNoteId); + } + return; + } catch { + // Fallback to creating/selecting the configured scratchpad. + } + } + + try { + const noteId = await notesService.getOrCreateMinimalScratchpad(); + if (!cancelled) { + await selectNote(noteId); + } + } catch (error) { + if (!cancelled) { + console.error("Failed to initialize minimal scratchpad:", error); + toast.error("Failed to open minimal scratchpad"); + } + } + }; + + const initialNoteId = new URLSearchParams(window.location.search).get( + "noteId", + ); + void openScratchpad(initialNoteId); + + listen<{ noteId: string }>("minimal-open-note", (event) => { + void openScratchpad(event.payload?.noteId); + }).then((fn) => { + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + }); + + return () => { + cancelled = true; + if (unlisten) { + unlisten(); + } + }; + }, [isLoading, minimalMode, notesFolder, selectNote]); // Go back to command palette from AI modal const handleBackToPalette = useCallback(() => { @@ -164,6 +289,10 @@ function AppContent() { const isInInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; + if (minimalMode) { + return; + } + // Open settings always works, even while already on settings. if ( openSettingsShortcut && @@ -307,6 +436,7 @@ function AppContent() { toggleAlwaysOnTop, view, shortcuts, + minimalMode, ]); const handleClosePalette = useCallback(() => { @@ -328,6 +458,14 @@ function AppContent() { return ; } + if (minimalMode) { + return ( +
+ +
+ ); + } + return ( <>
@@ -460,6 +598,13 @@ function UpdateToast({ } function App() { + let isMinimalWindow = false; + try { + isMinimalWindow = getCurrentWindow().label === "minimal"; + } catch { + // Running without Tauri (e.g. plain Vite dev server). + } + // Add platform class for OS-specific styling (e.g., keyboard shortcuts) useEffect(() => { const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); @@ -470,9 +615,10 @@ function App() { // Check for app updates on startup useEffect(() => { + if (isMinimalWindow) return; const timer = setTimeout(() => showUpdateToast(), 3000); return () => clearTimeout(timer); - }, []); + }, [isMinimalWindow]); return ( @@ -480,7 +626,7 @@ function App() { - + diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index bfc9d60..dc752bc 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -124,6 +124,21 @@ export function CommandPalette({ } }, }, + { + id: "open-minimal-editor", + label: "Toggle Minimal Scratchpad", + shortcut: getShortcutDisplayText(shortcuts.openMinimalEditor), + icon: , + action: async () => { + try { + await notesService.toggleMinimalEditor(); + onClose(); + } catch (error) { + console.error("Failed to toggle minimal editor:", error); + toast.error("Failed to toggle minimal scratchpad"); + } + }, + }, ]; // Add note-specific commands if a note is selected diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index a6be39a..33e5321 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -352,9 +352,14 @@ function FormatBar({ interface EditorProps { onToggleSidebar?: () => void; sidebarVisible?: boolean; + minimalMode?: boolean; } -export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { +export function Editor({ + onToggleSidebar, + sidebarVisible, + minimalMode = false, +}: EditorProps) { const { shortcuts } = useTheme(); const { notes, @@ -400,7 +405,10 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { let markdown = manager.serialize(editorInstance.getJSON()); // Clean up nbsp entities in table cells (TipTap adds these to empty cells) // Match table rows and remove one or more   or   from cells - markdown = markdown.replace(/(\|)\s*(?: | )+\s*(?=\|)/g, "$1 "); + markdown = markdown.replace( + /(\|)\s*(?: | )+\s*(?=\|)/g, + "$1 ", + ); return markdown; } // Fallback to plain text @@ -619,8 +627,9 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { ], editorProps: { attributes: { - class: - "prose prose-lg dark:prose-invert max-w-3xl mx-auto focus:outline-none min-h-full px-6 pt-8 pb-24", + class: minimalMode + ? "prose prose-lg dark:prose-invert max-w-none mx-auto focus:outline-none min-h-full px-4 pt-4 pb-10" + : "prose prose-lg dark:prose-invert max-w-3xl mx-auto focus:outline-none min-h-full px-6 pt-8 pb-24", }, // Trap Tab key inside the editor handleKeyDown: (_view, event) => { @@ -1144,7 +1153,10 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { defaultItalicShortcut && matchesParsedShortcut(e, defaultItalicShortcut); - if ((boldChanged && pressedDefaultBold) || (italicChanged && pressedDefaultItalic)) { + if ( + (boldChanged && pressedDefaultBold) || + (italicChanged && pressedDefaultItalic) + ) { e.preventDefault(); e.stopPropagation(); } @@ -1197,7 +1209,10 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { const findInNoteShortcut = parseShortcut(shortcuts.findInNote); const handleKeyDown = (e: KeyboardEvent) => { - if (!findInNoteShortcut || !matchesParsedShortcut(e, findInNoteShortcut)) { + if ( + !findInNoteShortcut || + !matchesParsedShortcut(e, findInNoteShortcut) + ) { return; } @@ -1279,15 +1294,32 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { }, [editor]); const createNoteShortcutLabel = getShortcutDisplayText(shortcuts.createNote); - const toggleSidebarShortcutLabel = getShortcutDisplayText(shortcuts.toggleSidebar); - const reloadNoteShortcutLabel = getShortcutDisplayText(shortcuts.reloadCurrentNote); + const toggleSidebarShortcutLabel = getShortcutDisplayText( + shortcuts.toggleSidebar, + ); + const reloadNoteShortcutLabel = getShortcutDisplayText( + shortcuts.reloadCurrentNote, + ); const boldShortcutLabel = getShortcutDisplayText(shortcuts.bold); const italicShortcutLabel = getShortcutDisplayText(shortcuts.italic); const findInNoteShortcutLabel = getShortcutDisplayText(shortcuts.findInNote); const copyAsShortcutLabel = getShortcutDisplayText(shortcuts.copyAs); - const addOrEditLinkShortcutLabel = getShortcutDisplayText(shortcuts.addOrEditLink); + const addOrEditLinkShortcutLabel = getShortcutDisplayText( + shortcuts.addOrEditLink, + ); if (!currentNote) { + if (minimalMode) { + return ( +
+
+
+ Opening scratchpad... +
+
+ ); + } + return (
{/* Drag region */} @@ -1315,7 +1347,9 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { className="mt-4" > New Note{" "} - {createNoteShortcutLabel} + + {createNoteShortcutLabel} +
@@ -1329,7 +1363,8 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) {
@@ -1347,9 +1382,11 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { )} - - {formatDateTime(currentNote.modified)} - + {!minimalMode && ( + + {formatDateTime(currentNote.modified)} + + )}
{hasExternalChanges ? ( @@ -1377,7 +1414,7 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) {
)} - {currentNote && ( + {!minimalMode && currentNote && ( { @@ -1412,77 +1449,84 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { )} - {currentNote && ( + {!minimalMode && currentNote && ( setSearchOpen(true)}> )} - - - - - - - - - - { - // Prevent focus returning to trigger button - e.preventDefault(); - }} - onKeyDown={(e) => { - // Stop arrow keys from bubbling to note list navigation - if (e.key === "ArrowUp" || e.key === "ArrowDown") { - e.stopPropagation(); - } - }} - > - - Markdown - - - Plain Text - - + + + + + + + + + { + // Prevent focus returning to trigger button + e.preventDefault(); + }} + onKeyDown={(e) => { + // Stop arrow keys from bubbling to note list navigation + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.stopPropagation(); + } + }} > - HTML - - - - + + Markdown + + + Plain Text + + + HTML + + + + + )}
{/* Format Bar */} - + {!minimalMode && ( + + )} {/* TipTap Editor */}
- {searchOpen && ( + {!minimalMode && searchOpen && (
editor.chain().focus().addColumnBefore().run(), + action: () => + editor.chain().focus().addColumnBefore().run(), }), ); } @@ -1607,7 +1654,9 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { action: () => editor.chain().focus().deleteColumn().run(), }), ); - menuItems.push(await PredefinedMenuItem.new({ item: "Separator" })); + menuItems.push( + await PredefinedMenuItem.new({ item: "Separator" }), + ); // Only show "Add Row Above" if not in first row if (!isFirstRow) { @@ -1630,7 +1679,9 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { action: () => editor.chain().focus().deleteRow().run(), }), ); - menuItems.push(await PredefinedMenuItem.new({ item: "Separator" })); + menuItems.push( + await PredefinedMenuItem.new({ item: "Separator" }), + ); menuItems.push( await MenuItem.new({ text: "Toggle Header Row", @@ -1640,10 +1691,13 @@ export function Editor({ onToggleSidebar, sidebarVisible }: EditorProps) { menuItems.push( await MenuItem.new({ text: "Toggle Header Column", - action: () => editor.chain().focus().toggleHeaderColumn().run(), + action: () => + editor.chain().focus().toggleHeaderColumn().run(), }), ); - menuItems.push(await PredefinedMenuItem.new({ item: "Separator" })); + menuItems.push( + await PredefinedMenuItem.new({ item: "Separator" }), + ); menuItems.push( await MenuItem.new({ text: "Delete Table", diff --git a/src/components/settings/ShortcutsSettingsSection.tsx b/src/components/settings/ShortcutsSettingsSection.tsx index 25924ca..1ed2bea 100644 --- a/src/components/settings/ShortcutsSettingsSection.tsx +++ b/src/components/settings/ShortcutsSettingsSection.tsx @@ -51,6 +51,12 @@ const shortcutRows: ShortcutRow[] = [ description: "Toggle always on top", category: "Navigation", }, + { + type: "editable", + action: "openMinimalEditor", + description: "Toggle minimal scratchpad", + category: "Navigation", + }, { type: "editable", action: "navigateNoteUp", @@ -200,7 +206,7 @@ export function ShortcutsSettingsSection() { setCaptureError(null); } else { setCaptureError( - "This shortcut must include Cmd/Ctrl. Note navigation can be plain keys.", + "Most shortcuts must include Cmd/Ctrl. Note navigation and minimal toggle can use other modifiers.", ); } }; @@ -224,8 +230,9 @@ export function ShortcutsSettingsSection() {

Click a shortcut and press your preferred key combination. Press - Escape to cancel. Most shortcuts require Cmd/Ctrl, except note - navigation. + Escape to cancel. Most shortcuts require Cmd/Ctrl. Note navigation + and minimal scratchpad toggle can use other modifier combos like + Option+Space.

{captureError &&

{captureError}

} diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index d6fdd39..5395f53 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -264,9 +264,14 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const parsed = parseShortcut(normalized); if (!parsed) return false; - const allowNoModifier = - action === "navigateNoteUp" || action === "navigateNoteDown"; - if (!allowNoModifier && !parsed.mod) return false; + const hasAnyModifier = parsed.mod || parsed.alt || parsed.shift; + const allowWithoutCmdCtrl = + action === "navigateNoteUp" || + action === "navigateNoteDown" || + action === "openMinimalEditor"; + + if (!allowWithoutCmdCtrl && !parsed.mod) return false; + if (action === "openMinimalEditor" && !hasAnyModifier) return false; setShortcuts((prev) => { const updated = { ...prev, [action]: normalized }; diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 7b5bd7a..9a54ab0 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -13,6 +13,7 @@ export const DEFAULT_SHORTCUTS: Record = { createNote: "Mod+N", reloadCurrentNote: "Mod+R", toggleAlwaysOnTop: "Mod+Shift+T", + openMinimalEditor: "Mod+Shift+M", openSettings: "Mod+,", toggleSidebar: "Mod+\\", navigateNoteUp: "ArrowUp", @@ -83,7 +84,8 @@ function normalizeKeyToken(token: string): string | null { function normalizeEventKey(key: string): string | null { if (!key) return null; - if (key === " ") return "Space"; + // macOS Option+Space can emit a non-breaking space instead of plain space. + if (key === " " || (key.length === 1 && key.trim() === "")) return "Space"; if (key === "Esc") return "Escape"; if (key.length === 1) { diff --git a/src/services/notes.ts b/src/services/notes.ts index b926fd4..04438e7 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -65,3 +65,15 @@ export async function startFileWatcher(): Promise { export async function toggleAlwaysOnTop(): Promise { return invoke("toggle_always_on_top"); } + +export async function toggleMinimalEditor(): Promise { + return invoke("toggle_minimal_editor"); +} + +export async function getOrCreateMinimalScratchpad(): Promise { + return invoke("get_or_create_minimal_scratchpad"); +} + +export async function setLastSelectedScratchpad(id: string | null): Promise { + return invoke("set_last_selected_scratchpad", { id }); +} diff --git a/src/types/note.ts b/src/types/note.ts index 5092aa6..34dafcc 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -24,6 +24,7 @@ export type ShortcutAction = | "createNote" | "reloadCurrentNote" | "toggleAlwaysOnTop" + | "openMinimalEditor" | "openSettings" | "toggleSidebar" | "navigateNoteUp" @@ -53,4 +54,5 @@ export interface Settings { gitEnabled?: boolean; pinnedNoteIds?: string[]; shortcuts?: ShortcutSettings; + lastSelectedScratchpadId?: string; } From b0a9822aa239ec7a5598ffa352cb2e6bf52499da Mon Sep 17 00:00:00 2001 From: "Timo S." Date: Thu, 12 Feb 2026 14:25:53 +0100 Subject: [PATCH 4/4] shortcut recording style & fix for invalid keys --- .../settings/ShortcutsSettingsSection.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/settings/ShortcutsSettingsSection.tsx b/src/components/settings/ShortcutsSettingsSection.tsx index 1ed2bea..25f40dd 100644 --- a/src/components/settings/ShortcutsSettingsSection.tsx +++ b/src/components/settings/ShortcutsSettingsSection.tsx @@ -205,6 +205,7 @@ export function ShortcutsSettingsSection() { setCapturingAction(null); setCaptureError(null); } else { + setCapturingAction(null); setCaptureError( "Most shortcuts must include Cmd/Ctrl. Note navigation and minimal toggle can use other modifiers.", ); @@ -230,11 +231,13 @@ export function ShortcutsSettingsSection() {

Click a shortcut and press your preferred key combination. Press - Escape to cancel. Most shortcuts require Cmd/Ctrl. Note navigation - and minimal scratchpad toggle can use other modifier combos like + Escape to cancel. Most shortcuts require Cmd/Ctrl. Note navigation and + minimal scratchpad toggle can use other modifier combos like Option+Space.

- {captureError &&

{captureError}

} + {captureError && ( +

{captureError}

+ )} {categoryOrder.map((category, idx) => { @@ -243,7 +246,9 @@ export function ShortcutsSettingsSection() { return (
- {idx > 0 &&
} + {idx > 0 && ( +
+ )}

{category}

@@ -264,14 +269,14 @@ export function ShortcutsSettingsSection() {