mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
46
Commits
v1.3.4
...
c4a91b8f1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a91b8f1c | ||
|
|
87c879bcac | ||
|
|
27609fb4f3 | ||
|
|
b0555fe648 | ||
|
|
32ef51161d | ||
|
|
23abd0a19a | ||
|
|
5f726eb01f | ||
|
|
73f7604187 | ||
|
|
7ca226d918 | ||
|
|
1cbdf704bd | ||
|
|
b5bd5ea151 | ||
|
|
bdf94e2494 | ||
|
|
1950c4cc3c | ||
|
|
aeddcc6e58 | ||
|
|
bb9d01f57c | ||
|
|
ac25dfd773 | ||
|
|
e61c66dc26 | ||
|
|
3e8aa7f10d | ||
|
|
2dd0985ea9 | ||
|
|
18bb6bb702 | ||
|
|
fcf48a5b7a | ||
|
|
c7906f4069 | ||
|
|
e6e1ed1ed6 | ||
|
|
1a880a4f96 | ||
|
|
f4e05ed9c4 | ||
|
|
4a1045b41e | ||
|
|
c305b5f8dd | ||
|
|
b5962af466 | ||
|
|
13e83b6689 | ||
|
|
432e4f472a | ||
|
|
8731ca4ec1 | ||
|
|
6b165059cc | ||
|
|
213797a38e | ||
|
|
fc3e388b80 | ||
|
|
1c0359e540 | ||
|
|
da859e581b | ||
|
|
763e673738 | ||
|
|
550d3ab3e9 | ||
|
|
e62404d4c0 | ||
|
|
54e051ba02 | ||
|
|
344728dbe8 | ||
|
|
818e9ad82a | ||
|
|
4931ee3fab | ||
|
|
65e09e8fdd | ||
|
|
64e521d549 | ||
|
|
b8308d89e8 |
@@ -13,6 +13,9 @@ bun.lockb
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local tests
|
||||
/tests/
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,29 +10,45 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.3.3)
|
||||
## Download (v1.3.4)
|
||||
|
||||
### Linux
|
||||
|
||||
#### Arch / Manjaro (AUR)
|
||||
#### AppImage
|
||||
|
||||
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||
|
||||
#### Distro-specific packages
|
||||
|
||||
##### Fedora 43+ (DNF)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
sudo dnf config-manager addrepo \
|
||||
--from-repofile=https://repo.arkhost.com/helixnotes.repo
|
||||
sudo dnf install helix-notes
|
||||
```
|
||||
|
||||
#### Debian / Ubuntu / Mint (APT)
|
||||
##### Debian / Ubuntu / Mint (APT)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
|
||||
```
|
||||
|
||||
#### Solus (EOPKG)
|
||||
##### Arch / Manjaro (AUR)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
```
|
||||
|
||||
##### Solus (EOPKG)
|
||||
|
||||
```bash
|
||||
sudo eopkg it helixnotes
|
||||
```
|
||||
|
||||
#### NixOS
|
||||
##### NixOS
|
||||
|
||||
<details>
|
||||
<summary>flake.nix</summary>
|
||||
@@ -90,23 +106,18 @@ sudo eopkg it helixnotes
|
||||
```
|
||||
</details>
|
||||
|
||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||
#### Manual package downloads
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.3/HelixNotes_1.3.3_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg (Apple Silicon)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_aarch64.dmg) (M-series Macs)
|
||||
|
||||
[Download .dmg (Intel)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
|
||||
|
||||
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||
>
|
||||
@@ -116,7 +127,7 @@ sudo eopkg it helixnotes
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_android.apk)
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1782959384,
|
||||
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-is-fullscreen",
|
||||
|
||||
+129
-12
@@ -331,6 +331,19 @@ pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_system_themes(
|
||||
state: State<'_, AppState>,
|
||||
light: String,
|
||||
dark: String,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.system_light_theme = light;
|
||||
config.system_dark_theme = dark;
|
||||
save_app_config(&config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
@@ -354,12 +367,41 @@ pub fn save_custom_theme(state: State<'_, AppState>, theme: crate::types::Custom
|
||||
#[tauri::command]
|
||||
pub fn delete_custom_theme(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
clear_custom_theme_references(&mut config, &id);
|
||||
save_app_config(&config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_custom_theme_references(config: &mut AppConfig, id: &str) {
|
||||
config.custom_themes.retain(|t| t.id != id);
|
||||
if config.theme == id {
|
||||
config.theme = "system".to_string();
|
||||
}
|
||||
save_app_config(&config)?;
|
||||
Ok(())
|
||||
if config.system_light_theme == id {
|
||||
config.system_light_theme = "light".to_string();
|
||||
}
|
||||
if config.system_dark_theme == id {
|
||||
config.system_dark_theme = "dark".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod custom_theme_reference_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deleting_custom_theme_resets_system_pair_references() {
|
||||
let mut config = AppConfig::default();
|
||||
config.theme = "custom-work".to_string();
|
||||
config.system_light_theme = "custom-work".to_string();
|
||||
config.system_dark_theme = "custom-work".to_string();
|
||||
|
||||
clear_custom_theme_references(&mut config, "custom-work");
|
||||
|
||||
assert_eq!(config.theme, "system");
|
||||
assert_eq!(config.system_light_theme, "light");
|
||||
assert_eq!(config.system_dark_theme, "dark");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -420,11 +462,19 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String> {
|
||||
pub fn set_ui_scale(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
scale: f64,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.ui_scale = Some(scale);
|
||||
save_app_config(&config)?;
|
||||
Ok(())
|
||||
drop(config);
|
||||
|
||||
use tauri::Emitter;
|
||||
app.emit("ui-scale-changed", scale)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -505,15 +555,17 @@ pub fn move_notebook(
|
||||
|
||||
// Update notebook icon mappings
|
||||
if let Ok(icons) = operations::load_notebook_icons(vault_path) {
|
||||
let old_prefix = format!("{}/", old_relative);
|
||||
let old_icon_key = operations::normalize_notebook_icon_key(&old_relative);
|
||||
let new_icon_key = operations::normalize_notebook_icon_key(&new_relative);
|
||||
let old_prefix = format!("{}/", old_icon_key);
|
||||
let mut new_icons = std::collections::HashMap::new();
|
||||
let mut changed = false;
|
||||
for (key, value) in &icons {
|
||||
if *key == old_relative {
|
||||
new_icons.insert(new_relative.clone(), value.clone());
|
||||
if *key == old_icon_key {
|
||||
new_icons.insert(new_icon_key.clone(), value.clone());
|
||||
changed = true;
|
||||
} else if key.starts_with(&old_prefix) {
|
||||
let new_key = format!("{}/{}", new_relative, &key[old_prefix.len()..]);
|
||||
let new_key = format!("{}/{}", new_icon_key, &key[old_prefix.len()..]);
|
||||
new_icons.insert(new_key, value.clone());
|
||||
changed = true;
|
||||
} else {
|
||||
@@ -596,6 +648,20 @@ pub fn save_note(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn duplicate_note(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Result<crate::types::NoteEntry, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault = config.active_vault.as_ref().ok_or("No active vault")?.clone();
|
||||
drop(config);
|
||||
|
||||
let entry = operations::duplicate_note(&path, &vault)?;
|
||||
index_note_bg(&state, &entry.path);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_note(
|
||||
state: State<'_, AppState>,
|
||||
@@ -743,6 +809,26 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_note_switcher_titles(
|
||||
state: State<'_, AppState>,
|
||||
recent_paths: Vec<String>,
|
||||
) -> Result<Vec<NoteTitleEntry>, String> {
|
||||
let vault_path = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.active_vault
|
||||
.clone()
|
||||
.ok_or_else(|| "No active vault".to_string())?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operations::get_note_switcher_titles(&vault_path, &recent_paths)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
// ── Graph ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1231,6 +1317,23 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
|
||||
|
||||
// ── Clipboard ──
|
||||
|
||||
/// Copy text to the system clipboard through the native backend.
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
let mut clipboard =
|
||||
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard
|
||||
.set_text(text)
|
||||
.map_err(|e| format!("Failed to copy text: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Text clipboard copy is only supported on desktop".to_string())
|
||||
}
|
||||
|
||||
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
|
||||
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
|
||||
#[cfg(desktop)]
|
||||
@@ -1381,10 +1484,13 @@ pub fn set_general_settings(
|
||||
show_line_numbers: bool,
|
||||
show_link_arrows: bool,
|
||||
default_view_mode: bool,
|
||||
new_notes_in_source_mode: bool,
|
||||
show_tray_icon: bool,
|
||||
close_to_tray: bool,
|
||||
enable_wiki_links: bool,
|
||||
show_note_dates: bool,
|
||||
show_note_switcher: bool,
|
||||
startup_view: StartupView,
|
||||
restore_last_session: bool,
|
||||
show_all_notes: bool,
|
||||
show_quick_access: bool,
|
||||
@@ -1395,6 +1501,8 @@ pub fn set_general_settings(
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.compact_notes = compact_notes;
|
||||
config.show_note_dates = show_note_dates;
|
||||
config.show_note_switcher = show_note_switcher;
|
||||
config.startup_view = startup_view;
|
||||
config.restore_last_session = restore_last_session;
|
||||
config.show_all_notes = show_all_notes;
|
||||
config.show_quick_access = show_quick_access;
|
||||
@@ -1413,6 +1521,7 @@ pub fn set_general_settings(
|
||||
config.show_line_numbers = show_line_numbers;
|
||||
config.show_link_arrows = show_link_arrows;
|
||||
config.default_view_mode = default_view_mode;
|
||||
config.new_notes_in_source_mode = new_notes_in_source_mode;
|
||||
config.show_tray_icon = show_tray_icon;
|
||||
config.close_to_tray = close_to_tray;
|
||||
config.enable_wiki_links = enable_wiki_links;
|
||||
@@ -1423,10 +1532,18 @@ pub fn set_general_settings(
|
||||
// ── Quick Access ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_quick_access(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
operations::get_quick_access_notes(vault_path)
|
||||
pub async fn get_quick_access(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> {
|
||||
let vault_path = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.active_vault
|
||||
.clone()
|
||||
.ok_or_else(|| "No active vault".to_string())?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || operations::get_quick_access_notes(&vault_path))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+39
-10
@@ -59,16 +59,9 @@ pub fn run() {
|
||||
.on_page_load(|webview, payload| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if webview.label() == "main"
|
||||
&& matches!(
|
||||
payload.event(),
|
||||
tauri::webview::PageLoadEvent::Finished
|
||||
)
|
||||
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
|
||||
{
|
||||
let window = webview.window();
|
||||
let window_on_main = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
fix_macos_traffic_lights(&window_on_main);
|
||||
});
|
||||
queue_macos_traffic_light_fix(&webview.window());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (webview, payload);
|
||||
@@ -137,6 +130,7 @@ pub fn run() {
|
||||
commands::remove_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
commands::set_system_themes,
|
||||
commands::set_accent_color,
|
||||
commands::save_custom_theme,
|
||||
commands::delete_custom_theme,
|
||||
@@ -157,12 +151,14 @@ pub fn run() {
|
||||
commands::read_note,
|
||||
commands::save_note,
|
||||
commands::create_note,
|
||||
commands::duplicate_note,
|
||||
commands::create_daily_note,
|
||||
commands::rename_note,
|
||||
commands::delete_note,
|
||||
commands::move_note,
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::get_note_switcher_titles,
|
||||
commands::get_graph_data,
|
||||
commands::get_tasks,
|
||||
commands::set_task_done,
|
||||
@@ -177,6 +173,7 @@ pub fn run() {
|
||||
commands::empty_trash,
|
||||
commands::load_vault_state,
|
||||
commands::save_vault_state,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::read_clipboard_image,
|
||||
commands::copy_image_to_clipboard,
|
||||
commands::save_image,
|
||||
@@ -318,9 +315,33 @@ pub fn run() {
|
||||
}));
|
||||
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
let window_state_builder = tauri_plugin_window_state::Builder::default();
|
||||
#[cfg(target_os = "linux")]
|
||||
let window_state_builder = window_state_builder.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::SIZE
|
||||
| tauri_plugin_window_state::StateFlags::POSITION
|
||||
| tauri_plugin_window_state::StateFlags::MAXIMIZED
|
||||
| tauri_plugin_window_state::StateFlags::DECORATIONS
|
||||
| tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
);
|
||||
builder = builder.plugin(window_state_builder.build());
|
||||
|
||||
builder = builder.on_window_event(move |window, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if window.label() == "main"
|
||||
&& matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Resized(_)
|
||||
| tauri::WindowEvent::Focused(true)
|
||||
| tauri::WindowEvent::ScaleFactorChanged { .. }
|
||||
| tauri::WindowEvent::ThemeChanged(_)
|
||||
)
|
||||
{
|
||||
// AppKit can restore the default button Y position after window-state
|
||||
// changes. Queue this pass so it runs after AppKit finishes its layout.
|
||||
queue_macos_traffic_light_fix(window);
|
||||
}
|
||||
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
// Only hide to tray for the main window
|
||||
@@ -410,6 +431,14 @@ fn percent_decode(input: &str) -> String {
|
||||
String::from_utf8_lossy(&output).to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn queue_macos_traffic_light_fix(window: &tauri::Window) {
|
||||
let window_on_main = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
fix_macos_traffic_lights(&window_on_main);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fix_macos_traffic_lights(window: &tauri::Window) {
|
||||
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::search::SearchIndex;
|
||||
use crate::types::AppConfig;
|
||||
use notify::RecommendedWatcher;
|
||||
use crate::vault::watcher::VaultWatcher;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub watcher: Mutex<Option<VaultWatcher>>,
|
||||
pub vault_transition: tokio::sync::Mutex<()>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
|
||||
+32
-7
@@ -6,9 +6,9 @@
|
||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||
// conflict copies so nothing is ever lost.
|
||||
//
|
||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||
// manifest itself) is local-only and never synced.
|
||||
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
||||
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
||||
// and the manifest itself remain local-only.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
@@ -119,12 +119,12 @@ struct LocalFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||
/// folder with unrelated files never drags them into the vault.
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
||||
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
|
||||
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||
fn is_synced_relpath(rel: &str) -> bool {
|
||||
if rel.starts_with(".helixnotes/") {
|
||||
rel.starts_with(".helixnotes/attachments/")
|
||||
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
|
||||
} else {
|
||||
rel.ends_with(".md")
|
||||
}
|
||||
@@ -724,3 +724,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_synced_relpath;
|
||||
|
||||
#[test]
|
||||
fn syncs_notebook_icon_mapping_and_assets_only() {
|
||||
for path in [
|
||||
"Notes/plan.md",
|
||||
".helixnotes/attachments/notebook-icon.png",
|
||||
".helixnotes/notebook_icons.json",
|
||||
] {
|
||||
assert!(is_synced_relpath(path), "expected {path} to be synced");
|
||||
}
|
||||
|
||||
for path in [
|
||||
"Notes/image.png",
|
||||
".helixnotes/sync_state.json",
|
||||
".helixnotes/notebook_icons.json.bak",
|
||||
".helixnotes/attachments-old/icon.png",
|
||||
] {
|
||||
assert!(!is_synced_relpath(path), "expected {path} to stay local");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ pub struct CustomTheme {
|
||||
pub colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StartupView {
|
||||
Daily,
|
||||
QuickAccess,
|
||||
Tasks,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub vaults: Vec<VaultConfig>,
|
||||
@@ -103,6 +114,12 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub active_bookmark_id: Option<String>,
|
||||
pub theme: String,
|
||||
/// Themes used when `theme` is "system": the frontend picks one by the OS color scheme.
|
||||
/// Default to the plain "light"/"dark" schemes so existing configs keep their behavior.
|
||||
#[serde(default = "default_system_light_theme")]
|
||||
pub system_light_theme: String,
|
||||
#[serde(default = "default_system_dark_theme")]
|
||||
pub system_dark_theme: String,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -120,6 +137,8 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub show_note_dates: bool,
|
||||
#[serde(default)]
|
||||
pub show_note_switcher: bool,
|
||||
#[serde(default)]
|
||||
pub time_format: String,
|
||||
#[serde(default = "default_week_start")]
|
||||
pub week_start: String,
|
||||
@@ -186,12 +205,16 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub default_view_mode: bool,
|
||||
#[serde(default)]
|
||||
pub new_notes_in_source_mode: bool,
|
||||
#[serde(default)]
|
||||
pub show_tray_icon: bool,
|
||||
#[serde(default)]
|
||||
pub close_to_tray: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_wiki_links: bool,
|
||||
#[serde(default)]
|
||||
pub startup_view: StartupView,
|
||||
#[serde(default)]
|
||||
pub restore_last_session: bool,
|
||||
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||
#[serde(default)]
|
||||
@@ -254,6 +277,14 @@ fn default_ai_model() -> String {
|
||||
"claude-sonnet-4-6".to_string()
|
||||
}
|
||||
|
||||
fn default_system_light_theme() -> String {
|
||||
"light".to_string()
|
||||
}
|
||||
|
||||
fn default_system_dark_theme() -> String {
|
||||
"dark".to_string()
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -261,6 +292,8 @@ impl Default for AppConfig {
|
||||
active_vault: None,
|
||||
active_bookmark_id: None,
|
||||
theme: "system".to_string(),
|
||||
system_light_theme: default_system_light_theme(),
|
||||
system_dark_theme: default_system_dark_theme(),
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
font_family: None,
|
||||
@@ -269,6 +302,7 @@ impl Default for AppConfig {
|
||||
content_width: None,
|
||||
compact_notes: false,
|
||||
show_note_dates: true,
|
||||
show_note_switcher: false,
|
||||
time_format: "relative".to_string(),
|
||||
week_start: "monday".to_string(),
|
||||
daily_title_format: "localized".to_string(),
|
||||
@@ -302,9 +336,11 @@ impl Default for AppConfig {
|
||||
ai_model: "claude-sonnet-4-6".to_string(),
|
||||
ai_writing_style: None,
|
||||
default_view_mode: false,
|
||||
new_notes_in_source_mode: false,
|
||||
show_tray_icon: false,
|
||||
close_to_tray: false,
|
||||
enable_wiki_links: true,
|
||||
startup_view: StartupView::All,
|
||||
restore_last_session: false,
|
||||
sync_provider: None,
|
||||
webdav_url: None,
|
||||
@@ -469,3 +505,56 @@ pub struct TaskItem {
|
||||
pub due: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_view_tests {
|
||||
use super::{AppConfig, StartupView};
|
||||
|
||||
#[test]
|
||||
fn serializes_supported_startup_views() {
|
||||
for (view, expected) in [
|
||||
(StartupView::All, "\"all\""),
|
||||
(StartupView::QuickAccess, "\"quickaccess\""),
|
||||
(StartupView::Tasks, "\"tasks\""),
|
||||
(StartupView::Daily, "\"daily\""),
|
||||
] {
|
||||
assert_eq!(serde_json::to_string(&view).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_startup_view_falls_back_to_all_notes() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<StartupView>("\"future-view\"").unwrap(),
|
||||
StartupView::All
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_configs_default_system_theme_pair() {
|
||||
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("system_light_theme");
|
||||
object.remove("system_dark_theme");
|
||||
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(config.system_light_theme, "light");
|
||||
assert_eq!(config.system_dark_theme, "dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("show_note_switcher");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
|
||||
use crate::types::{
|
||||
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
|
||||
TrashNotebookEntry, VaultState,
|
||||
};
|
||||
use crate::vault::frontmatter;
|
||||
use chrono::{DateTime, Local, Locale, Utc};
|
||||
use rayon::prelude::*;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -43,6 +48,59 @@ pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn digit_run(value: &[u8], start: usize) -> (usize, &[u8]) {
|
||||
let mut end = start;
|
||||
while end < value.len() && value[end].is_ascii_digit() {
|
||||
end += 1;
|
||||
}
|
||||
let mut significant = start;
|
||||
while significant + 1 < end && value[significant] == b'0' {
|
||||
significant += 1;
|
||||
}
|
||||
(end, &value[significant..end])
|
||||
}
|
||||
|
||||
fn compare_natural_names(left: &str, right: &str) -> Ordering {
|
||||
let left_lower = left.to_lowercase();
|
||||
let right_lower = right.to_lowercase();
|
||||
let left = left_lower.as_bytes();
|
||||
let right = right_lower.as_bytes();
|
||||
let (mut left_index, mut right_index) = (0, 0);
|
||||
let mut leading_zero_order = Ordering::Equal;
|
||||
|
||||
while left_index < left.len() && right_index < right.len() {
|
||||
if left[left_index].is_ascii_digit() && right[right_index].is_ascii_digit() {
|
||||
let (left_end, left_number) = digit_run(left, left_index);
|
||||
let (right_end, right_number) = digit_run(right, right_index);
|
||||
let number_order = left_number
|
||||
.len()
|
||||
.cmp(&right_number.len())
|
||||
.then_with(|| left_number.cmp(right_number));
|
||||
if number_order != Ordering::Equal {
|
||||
return number_order;
|
||||
}
|
||||
if leading_zero_order == Ordering::Equal {
|
||||
leading_zero_order = (left_end - left_index).cmp(&(right_end - right_index));
|
||||
}
|
||||
left_index = left_end;
|
||||
right_index = right_end;
|
||||
} else {
|
||||
let character_order = left[left_index].cmp(&right[right_index]);
|
||||
if character_order != Ordering::Equal {
|
||||
return character_order;
|
||||
}
|
||||
left_index += 1;
|
||||
right_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
match (left_index == left.len(), right_index == right.len()) {
|
||||
(true, false) => Ordering::Less,
|
||||
(false, true) => Ordering::Greater,
|
||||
_ => leading_zero_order,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
|
||||
let root = Path::new(vault_path);
|
||||
if !root.exists() {
|
||||
@@ -69,10 +127,10 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
.collect();
|
||||
|
||||
dirs.sort_by(|a, b| {
|
||||
a.file_name()
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
compare_natural_names(
|
||||
&a.file_name().to_string_lossy(),
|
||||
&b.file_name().to_string_lossy(),
|
||||
)
|
||||
});
|
||||
|
||||
// Scan sibling notebooks in parallel: each subtree is independent, so
|
||||
@@ -123,10 +181,10 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
||||
}
|
||||
|
||||
subdirs.sort_by(|a, b| {
|
||||
a.file_name()
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
compare_natural_names(
|
||||
&a.file_name().to_string_lossy(),
|
||||
&b.file_name().to_string_lossy(),
|
||||
)
|
||||
});
|
||||
|
||||
let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
|
||||
@@ -476,6 +534,75 @@ pub fn create_note(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let parent = src
|
||||
.parent()
|
||||
.ok_or_else(|| "Note has no parent directory".to_string())?;
|
||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||
let filename = src
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (mut meta, body) = frontmatter::parse_note(&raw, &filename);
|
||||
let source_title = meta.title.clone();
|
||||
let base_title = format!("{} copy", source_title.trim_end());
|
||||
let mut copy_title = base_title.clone();
|
||||
let mut copy_number = 2;
|
||||
let mut copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||
|
||||
while copy_path.exists() {
|
||||
copy_title = format!("{} {}", base_title, copy_number);
|
||||
copy_number += 1;
|
||||
copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
meta.id = Uuid::new_v4().to_string();
|
||||
meta.title = copy_title;
|
||||
meta.pinned = false;
|
||||
meta.created = now;
|
||||
meta.modified = now;
|
||||
|
||||
let body = retitle_leading_heading(&body, &source_title, &meta.title);
|
||||
let copy_raw = frontmatter::merge_frontmatter(&raw, &meta, &body);
|
||||
fs::write(©_path, copy_raw).map_err(|e| e.to_string())?;
|
||||
|
||||
read_note_entry(©_path, Path::new(vault_path))
|
||||
}
|
||||
|
||||
fn retitle_leading_heading(body: &str, old_title: &str, new_title: &str) -> String {
|
||||
let trimmed = body.trim_start();
|
||||
let leading_len = body.len() - trimmed.len();
|
||||
let first_line_end = trimmed.find('\n').unwrap_or(trimmed.len());
|
||||
let first_line = &trimmed[..first_line_end];
|
||||
let heading_len = first_line.chars().take_while(|&c| c == '#').count();
|
||||
|
||||
if !(1..=6).contains(&heading_len) {
|
||||
return body.to_string();
|
||||
}
|
||||
|
||||
let Some(heading_title) = first_line[heading_len..].strip_prefix(' ') else {
|
||||
return body.to_string();
|
||||
};
|
||||
if !heading_title.trim().eq_ignore_ascii_case(old_title.trim()) {
|
||||
return body.to_string();
|
||||
}
|
||||
|
||||
let mut retitled = String::with_capacity(body.len() + new_title.len());
|
||||
retitled.push_str(&body[..leading_len]);
|
||||
retitled.push_str(&"#".repeat(heading_len));
|
||||
retitled.push(' ');
|
||||
retitled.push_str(new_title);
|
||||
retitled.push_str(&trimmed[first_line_end..]);
|
||||
retitled
|
||||
}
|
||||
|
||||
fn get_system_locale() -> Locale {
|
||||
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
|
||||
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
|
||||
@@ -1145,16 +1272,25 @@ pub fn save_attachment(vault_path: &str, name: &str, data: &[u8]) -> Result<Stri
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_notebook_icon_key(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
}
|
||||
|
||||
pub fn load_notebook_icons(
|
||||
vault_path: &str,
|
||||
) -> Result<std::collections::HashMap<String, String>, String> {
|
||||
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||
if icons_path.exists() {
|
||||
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(std::collections::HashMap::new())
|
||||
if !icons_path.exists() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?;
|
||||
let icons: std::collections::HashMap<String, String> =
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())?;
|
||||
Ok(icons
|
||||
.into_iter()
|
||||
.map(|(path, icon)| (normalize_notebook_icon_key(&path), icon))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn set_notebook_icon(
|
||||
@@ -1163,12 +1299,13 @@ pub fn set_notebook_icon(
|
||||
icon_relative: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut icons = load_notebook_icons(vault_path)?;
|
||||
let notebook_key = normalize_notebook_icon_key(notebook_relative);
|
||||
match icon_relative {
|
||||
Some(icon) => {
|
||||
icons.insert(notebook_relative.to_string(), icon.to_string());
|
||||
icons.insert(notebook_key, icon.to_string());
|
||||
}
|
||||
None => {
|
||||
icons.remove(notebook_relative);
|
||||
icons.remove(¬ebook_key);
|
||||
}
|
||||
}
|
||||
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||
@@ -1210,6 +1347,59 @@ pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const NOTE_SWITCHER_RECENT_LIMIT: usize = 6;
|
||||
|
||||
pub fn get_note_switcher_titles(
|
||||
vault_path: &str,
|
||||
recent_paths: &[String],
|
||||
) -> Result<Vec<NoteTitleEntry>, String> {
|
||||
let vault_root = Path::new(vault_path);
|
||||
if !vault_root.is_dir() {
|
||||
return Err("Vault path does not exist".to_string());
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut titles = Vec::with_capacity(NOTE_SWITCHER_RECENT_LIMIT);
|
||||
|
||||
for requested_path in recent_paths {
|
||||
if titles.len() >= NOTE_SWITCHER_RECENT_LIMIT {
|
||||
break;
|
||||
}
|
||||
|
||||
let path = Path::new(requested_path);
|
||||
let Ok(relative) = path.strip_prefix(vault_root) else {
|
||||
continue;
|
||||
};
|
||||
let safe_relative = !relative.as_os_str().is_empty()
|
||||
&& relative.components().all(|component| match component {
|
||||
Component::Normal(name) => !is_hidden(Path::new(name)),
|
||||
Component::CurDir => true,
|
||||
_ => false,
|
||||
});
|
||||
if !safe_relative
|
||||
|| path.extension().and_then(|extension| extension.to_str()) != Some("md")
|
||||
|| !path.is_file()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path = relative.to_path_buf();
|
||||
if !seen.insert(relative_path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(entry) = read_note_entry_fast(path, vault_root) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
titles.push(NoteTitleEntry {
|
||||
title: entry.meta.title,
|
||||
path: relative_path.to_string_lossy().replace('\\', "/"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(titles)
|
||||
}
|
||||
|
||||
pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||
let list = load_quick_access(vault_path)?;
|
||||
let vault_root = Path::new(vault_path);
|
||||
@@ -1218,7 +1408,7 @@ pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String
|
||||
for relative in &list {
|
||||
let full_path = vault_root.join(relative);
|
||||
if full_path.exists() {
|
||||
if let Ok(entry) = read_note_entry(&full_path, vault_root) {
|
||||
if let Ok(entry) = read_note_entry_fast(&full_path, vault_root) {
|
||||
notes.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -1237,3 +1427,200 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir,
|
||||
load_notebook_icons, scan_notebooks, set_notebook_icon,
|
||||
};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn compares_numeric_segments_anywhere_in_names() {
|
||||
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
||||
names.sort_by(|left, right| compare_natural_names(left, right));
|
||||
assert_eq!(
|
||||
names,
|
||||
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
|
||||
);
|
||||
assert_eq!(
|
||||
compare_natural_names("Class 02", "Class 2b"),
|
||||
std::cmp::Ordering::Less
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_notebooks_with_numeric_names_naturally() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-natural-sort-test-{}", Uuid::new_v4()));
|
||||
let nested = vault.join("Classes");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
let expected = ["5g", "6g", "7g", "8g", "9g", "10g", "11g", "12g"];
|
||||
for name in ["10g", "11g", "12g", "5g", "6g", "7g", "8g", "9g"] {
|
||||
fs::create_dir(vault.join(name)).unwrap();
|
||||
fs::create_dir(nested.join(name)).unwrap();
|
||||
}
|
||||
|
||||
let notebooks = scan_notebooks(&vault.to_string_lossy()).unwrap();
|
||||
let root_names: Vec<_> = notebooks
|
||||
.iter()
|
||||
.filter(|notebook| notebook.name != "Classes")
|
||||
.map(|notebook| notebook.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(root_names, expected);
|
||||
let nested_names: Vec<_> = notebooks
|
||||
.iter()
|
||||
.find(|notebook| notebook.name == "Classes")
|
||||
.unwrap()
|
||||
.children
|
||||
.iter()
|
||||
.map(|notebook| notebook.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(nested_names, expected);
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_and_removes_builtin_notebook_icons() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
|
||||
|
||||
set_notebook_icon(&vault_path, r"Projects\Client", Some("builtin:briefcase")).unwrap();
|
||||
let stored: std::collections::HashMap<String, String> = serde_json::from_str(
|
||||
&fs::read_to_string(helixnotes_dir(&vault_path).join("notebook_icons.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(stored.contains_key("Projects/Client"));
|
||||
assert!(!stored.contains_key(r"Projects\Client"));
|
||||
|
||||
let icons = load_notebook_icons(&vault_path).unwrap();
|
||||
assert_eq!(
|
||||
icons.get("Projects/Client").map(String::as_str),
|
||||
Some("builtin:briefcase")
|
||||
);
|
||||
|
||||
set_notebook_icon(&vault_path, "Projects/Client", None).unwrap();
|
||||
assert!(load_notebook_icons(&vault_path).unwrap().is_empty());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_legacy_notebook_icon_keys_when_loading() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let icons_path = helixnotes_dir(&vault_path).join("notebook_icons.json");
|
||||
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
|
||||
fs::write(&icons_path, r#"{"Projects\\Client":"builtin:folder"}"#).unwrap();
|
||||
|
||||
let icons = load_notebook_icons(&vault_path).unwrap();
|
||||
assert_eq!(
|
||||
icons.get("Projects/Client").map(String::as_str),
|
||||
Some("builtin:folder")
|
||||
);
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_only_requested_note_switcher_titles() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-note-switcher-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
let mut note_paths = Vec::new();
|
||||
for index in 0..8 {
|
||||
let path = vault.join(format!("Note {index}.md"));
|
||||
fs::write(
|
||||
&path,
|
||||
format!("---\ntitle: Note {index}\n---\n\nNote {index} body.\n"),
|
||||
)
|
||||
.unwrap();
|
||||
note_paths.push(path);
|
||||
}
|
||||
fs::write(
|
||||
vault.join("Unrelated.md"),
|
||||
"---\ntitle: Unrelated\n---\n\nMust not be loaded.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outside =
|
||||
std::env::temp_dir().join(format!("helixnotes-outside-note-{}.md", Uuid::new_v4()));
|
||||
fs::write(&outside, "---\ntitle: Outside\n---\n").unwrap();
|
||||
|
||||
let requested = vec![
|
||||
note_paths[0].to_string_lossy().into_owned(),
|
||||
note_paths[0].to_string_lossy().into_owned(),
|
||||
vault.join("Missing.md").to_string_lossy().into_owned(),
|
||||
outside.to_string_lossy().into_owned(),
|
||||
note_paths[1].to_string_lossy().into_owned(),
|
||||
note_paths[2].to_string_lossy().into_owned(),
|
||||
note_paths[3].to_string_lossy().into_owned(),
|
||||
note_paths[4].to_string_lossy().into_owned(),
|
||||
note_paths[5].to_string_lossy().into_owned(),
|
||||
note_paths[6].to_string_lossy().into_owned(),
|
||||
note_paths[7].to_string_lossy().into_owned(),
|
||||
];
|
||||
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let titles = get_note_switcher_titles(&vault_path, &requested).unwrap();
|
||||
assert_eq!(titles.len(), 6);
|
||||
assert_eq!(
|
||||
titles
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"Note 0.md",
|
||||
"Note 1.md",
|
||||
"Note 2.md",
|
||||
"Note 3.md",
|
||||
"Note 4.md",
|
||||
"Note 5.md",
|
||||
]
|
||||
);
|
||||
assert!(titles.iter().all(|entry| entry.title != "Unrelated"));
|
||||
assert!(titles.iter().all(|entry| entry.title != "Outside"));
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
fs::remove_file(outside).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-duplicate-note-test-{}", Uuid::new_v4()));
|
||||
let notebook = vault.join("Projects");
|
||||
fs::create_dir_all(¬ebook).unwrap();
|
||||
let source_path = notebook.join("Project.md");
|
||||
let source_raw = "---\nid: source-id\ntitle: Project\ntags:\n - work\npinned: true\ncreated: 2020-01-01T00:00:00Z\nmodified: 2020-01-02T00:00:00Z\naliases:\n - Plan\n---\n# Project\n\nOriginal body.\n";
|
||||
fs::write(&source_path, source_raw).unwrap();
|
||||
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let first = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||
let second = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||
|
||||
assert_eq!(first.meta.title, "Project copy");
|
||||
assert_eq!(second.meta.title, "Project copy 2");
|
||||
assert_eq!(first.meta.tags, vec!["work"]);
|
||||
assert!(!first.meta.pinned);
|
||||
assert_ne!(first.meta.id, "source-id");
|
||||
assert_ne!(first.meta.id, second.meta.id);
|
||||
assert_eq!(first.relative_path, "Projects/Project copy.md");
|
||||
assert_eq!(second.relative_path, "Projects/Project copy 2.md");
|
||||
|
||||
let first_raw = fs::read_to_string(&first.path).unwrap();
|
||||
assert!(first_raw.contains("aliases:\n- Plan"));
|
||||
assert!(first_raw.contains("# Project copy\n\nOriginal body."));
|
||||
assert!(!first_raw.contains("\n# Project\n"));
|
||||
assert_eq!(fs::read_to_string(&source_path).unwrap(), source_raw);
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
||||
use crate::types::FileEvent;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
|
||||
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WatcherBackend {
|
||||
Recommended,
|
||||
Poll,
|
||||
}
|
||||
|
||||
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
|
||||
if is_ios {
|
||||
WatcherBackend::Poll
|
||||
} else {
|
||||
WatcherBackend::Recommended
|
||||
}
|
||||
}
|
||||
|
||||
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
|
||||
match backend {
|
||||
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
|
||||
WatcherBackend::Poll => IOS_POLL_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum VaultWatcher {
|
||||
Recommended(RecommendedWatcher),
|
||||
Poll(PollWatcher),
|
||||
}
|
||||
|
||||
impl VaultWatcher {
|
||||
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
|
||||
match self {
|
||||
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
|
||||
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
tx,
|
||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||
let mut watcher = match backend {
|
||||
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
|
||||
),
|
||||
WatcherBackend::Poll => {
|
||||
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
|
||||
watcher
|
||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||
@@ -80,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ios_uses_polling_backend() {
|
||||
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_platforms_keep_recommended_backend() {
|
||||
assert_eq!(
|
||||
watcher_backend_for_target(false),
|
||||
WatcherBackend::Recommended
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_backends_use_the_expected_intervals() {
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Poll),
|
||||
Duration::from_secs(10)
|
||||
);
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Recommended),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
ExternalVaultResult,
|
||||
StartupView,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
@@ -42,6 +43,10 @@ export async function setTheme(theme: string): Promise<void> {
|
||||
return invoke("set_theme", { theme });
|
||||
}
|
||||
|
||||
export async function setSystemThemes(light: string, dark: string): Promise<void> {
|
||||
return invoke("set_system_themes", { light, dark });
|
||||
}
|
||||
|
||||
export async function setAccentColor(color: string): Promise<void> {
|
||||
return invoke("set_accent_color", { color });
|
||||
}
|
||||
@@ -140,6 +145,10 @@ export async function createNote(
|
||||
return invoke("create_note", { notebookRelative, title });
|
||||
}
|
||||
|
||||
export async function duplicateNote(path: string): Promise<NoteEntry> {
|
||||
return invoke("duplicate_note", { path });
|
||||
}
|
||||
|
||||
export async function createDailyNote(date?: string): Promise<NoteEntry> {
|
||||
return invoke("create_daily_note", { date: date ?? null });
|
||||
}
|
||||
@@ -170,6 +179,12 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_all_note_titles");
|
||||
}
|
||||
|
||||
export async function getNoteSwitcherTitles(
|
||||
recentPaths: string[],
|
||||
): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_note_switcher_titles", { recentPaths });
|
||||
}
|
||||
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
||||
return invoke("get_graph_data");
|
||||
}
|
||||
@@ -216,6 +231,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
|
||||
return invoke("save_vault_state", { vaultState });
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
return invoke("copy_text_to_clipboard", { text });
|
||||
}
|
||||
|
||||
export async function readClipboardImage(): Promise<number[]> {
|
||||
return invoke("read_clipboard_image");
|
||||
}
|
||||
@@ -260,10 +279,13 @@ export async function setGeneralSettings(
|
||||
showLineNumbers: boolean,
|
||||
showLinkArrows: boolean,
|
||||
defaultViewMode: boolean,
|
||||
newNotesInSourceMode: boolean,
|
||||
showTrayIcon: boolean,
|
||||
closeToTray: boolean,
|
||||
enableWikiLinks: boolean,
|
||||
showNoteDates: boolean,
|
||||
showNoteSwitcher: boolean,
|
||||
startupView: StartupView,
|
||||
restoreLastSession: boolean,
|
||||
showAllNotes: boolean,
|
||||
showQuickAccess: boolean,
|
||||
@@ -285,10 +307,13 @@ export async function setGeneralSettings(
|
||||
showLineNumbers,
|
||||
showLinkArrows,
|
||||
defaultViewMode,
|
||||
newNotesInSourceMode,
|
||||
showTrayIcon,
|
||||
closeToTray,
|
||||
enableWikiLinks,
|
||||
showNoteDates,
|
||||
showNoteSwitcher,
|
||||
startupView,
|
||||
restoreLastSession,
|
||||
showAllNotes,
|
||||
showQuickAccess,
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
showSearch,
|
||||
showCommandPalette,
|
||||
theme,
|
||||
resolvedTheme,
|
||||
customThemes,
|
||||
focusMode,
|
||||
readOnly,
|
||||
activeNote,
|
||||
@@ -61,12 +63,14 @@
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = $derived($platformIsMobile);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isAndroid } from '$lib/platform';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
|
||||
import { get } from 'svelte/store';
|
||||
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
|
||||
import type { StartupTarget } from '$lib/utils/startup-view';
|
||||
|
||||
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
|
||||
for (const nb of list) {
|
||||
@@ -81,6 +85,38 @@
|
||||
let noteList: NoteList;
|
||||
let editor: Editor;
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
|
||||
if (target.mode === 'notebook') {
|
||||
const vault = $appConfig?.active_vault;
|
||||
const notebook = target.notebookPath === '' && vault
|
||||
? { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount }
|
||||
: findNotebookByPath($notebooks, target.notebookPath);
|
||||
if (!notebook) return false;
|
||||
const changed = $viewMode !== 'notebook' || $activeNotebook?.relative_path !== notebook.relative_path;
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = notebook;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (target.mode === 'tag') {
|
||||
const changed = $viewMode !== 'tag' || $activeTag !== target.tag;
|
||||
$viewMode = 'tag';
|
||||
$activeTag = target.tag;
|
||||
$activeNotebook = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
const changed = $viewMode !== target.mode || $activeNotebook !== null || $activeTag !== null;
|
||||
$viewMode = target.mode;
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
if (changed) await noteList?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
let unlistenOpenFile: (() => void) | null = null;
|
||||
|
||||
// Mobile editor header helpers
|
||||
@@ -260,6 +296,24 @@
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
|
||||
const currentPath = $activeNotePath;
|
||||
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
|
||||
editor?.flushSave();
|
||||
try {
|
||||
const content = await readNote(path);
|
||||
$viewerNote = null;
|
||||
$activeNote = content;
|
||||
$activeNotePath = path;
|
||||
$editorDirty = false;
|
||||
handleNoteSelected(path, content.content);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to switch note:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewChanged() {
|
||||
taskNoteOpened = false;
|
||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||
@@ -415,6 +469,28 @@
|
||||
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
|
||||
}
|
||||
|
||||
async function trashOpenNote(path: string): Promise<boolean> {
|
||||
if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false;
|
||||
try {
|
||||
await deleteNote(path);
|
||||
if (Object.hasOwn($noteOrder, path)) {
|
||||
const { [path]: _, ...rest } = $noteOrder;
|
||||
$noteOrder = rest;
|
||||
}
|
||||
$notes = $notes.filter((note) => note.path !== path);
|
||||
if ($activeNotePath === path) {
|
||||
$activeNote = null;
|
||||
$activeNotePath = null;
|
||||
}
|
||||
noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error));
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to move open note to Trash:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
||||
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
||||
@@ -433,7 +509,7 @@
|
||||
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||
e.preventDefault();
|
||||
noteList?.trashActiveNote();
|
||||
editor?.moveOpenNoteToTrash();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,7 +527,8 @@
|
||||
createAndFocusNote();
|
||||
return;
|
||||
case 'toggle-theme': {
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
|
||||
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
@@ -509,23 +586,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
applyTheme($theme);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$collapsedNotebooks;
|
||||
persistState();
|
||||
@@ -599,18 +659,14 @@
|
||||
lastTag = state.last_tag ?? null;
|
||||
} catch (_) {}
|
||||
|
||||
const restoreLastSession = $appConfig?.restore_last_session === true;
|
||||
|
||||
// On mobile, prefetch last-opened note so first tap is instant
|
||||
let prefetchPromise: Promise<any> | null = null;
|
||||
if (isMobile && lastNotePath) {
|
||||
if (isMobile && restoreLastSession && lastNotePath) {
|
||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
||||
}
|
||||
|
||||
applyTheme($theme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if ($theme === 'system') applyTheme('system');
|
||||
});
|
||||
|
||||
// Run sidebar and note list refresh in parallel
|
||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||
|
||||
@@ -637,32 +693,26 @@
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Restore the last session (view + open note) if enabled.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
||||
$activeTag = null;
|
||||
await noteList?.refresh();
|
||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
||||
} else if (lastViewMode === 'tag' && lastTag) {
|
||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
||||
} else if (lastViewMode === 'quickaccess') {
|
||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
||||
}
|
||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
||||
if (!isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
const startupTarget = resolveStartupTarget({
|
||||
startupView: $appConfig?.startup_view,
|
||||
restoreLastSession,
|
||||
lastViewMode,
|
||||
lastNotebook,
|
||||
lastTag
|
||||
});
|
||||
if (!(await applyStartupTarget(startupTarget))) {
|
||||
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
|
||||
}
|
||||
|
||||
// Reopen the last note only when session restoration is enabled.
|
||||
if (restoreLastSession && !isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||
@@ -918,10 +968,10 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -965,7 +1015,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
|
||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
|
||||
{/if}
|
||||
<div class="app-layout">
|
||||
{#if !$focusMode}
|
||||
@@ -979,7 +1029,7 @@
|
||||
|
||||
{#if !$notelistCollapsed}
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
@@ -998,7 +1048,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="editor-panel">
|
||||
<Editor bind:this={editor} />
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
||||
<div class="tasks-editor-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
action: () => {
|
||||
$theme = 'system';
|
||||
setTheme('system');
|
||||
applyTheme('system');
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
},
|
||||
@@ -187,7 +186,7 @@
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (t === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
+534
-263
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { src, alt = '', onclose }: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 5;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const TAP_MOVE_THRESHOLD = 6;
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type ViewerGesture =
|
||||
| { kind: 'pan'; start: Point; pan: Point }
|
||||
| { kind: 'pinch'; distance: number; imagePoint: Point };
|
||||
|
||||
let canvasElement = $state<HTMLDivElement | null>(null);
|
||||
let imageElement = $state<HTMLImageElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let scale = $state(1);
|
||||
let panX = $state(0);
|
||||
let panY = $state(0);
|
||||
let interacting = $state(false);
|
||||
let gesture: ViewerGesture | null = null;
|
||||
let touchMoved = false;
|
||||
let lastTapAt = 0;
|
||||
let zoomPercent = $derived(Math.round(scale * 100));
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function midpoint(first: Touch, second: Touch): Point {
|
||||
return {
|
||||
x: (first.clientX + second.clientX) / 2,
|
||||
y: (first.clientY + second.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function touchDistance(first: Touch, second: Touch): number {
|
||||
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
||||
}
|
||||
|
||||
function panLimits(nextScale: number): Point {
|
||||
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
|
||||
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
|
||||
const imageWidth = imageElement?.clientWidth ?? 0;
|
||||
const imageHeight = imageElement?.clientHeight ?? 0;
|
||||
return {
|
||||
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
|
||||
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
|
||||
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||||
const limits = panLimits(clampedScale);
|
||||
scale = clampedScale;
|
||||
panX = clamp(nextPanX, -limits.x, limits.x);
|
||||
panY = clamp(nextPanY, -limits.y, limits.y);
|
||||
}
|
||||
|
||||
function resetViewer() {
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
applyTransform(MIN_SCALE, 0, 0);
|
||||
}
|
||||
|
||||
function zoomAt(point: Point, nextScale: number) {
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const imageX = (point.x - centerX - panX) / scale;
|
||||
const imageY = (point.y - centerY - panY) / scale;
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - imageX * nextScale,
|
||||
point.y - centerY - imageY * nextScale,
|
||||
);
|
||||
}
|
||||
|
||||
function beginPan(touch: Touch) {
|
||||
gesture = {
|
||||
kind: 'pan',
|
||||
start: { x: touch.clientX, y: touch.clientY },
|
||||
pan: { x: panX, y: panY },
|
||||
};
|
||||
}
|
||||
|
||||
function beginPinch(first: Touch, second: Touch) {
|
||||
const point = midpoint(first, second);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
gesture = {
|
||||
kind: 'pinch',
|
||||
distance: Math.max(1, touchDistance(first, second)),
|
||||
imagePoint: {
|
||||
x: (point.x - centerX - panX) / scale,
|
||||
y: (point.y - centerY - panY) / scale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = true;
|
||||
if (event.touches.length >= 2) {
|
||||
touchMoved = true;
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
touchMoved = false;
|
||||
beginPan(event.touches[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
|
||||
if (gesture?.kind !== 'pinch') return;
|
||||
const point = midpoint(event.touches[0], event.touches[1]);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const nextScale = clamp(
|
||||
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
);
|
||||
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - gesture.imagePoint.x * nextScale,
|
||||
point.y - centerY - gesture.imagePoint.y * nextScale,
|
||||
);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1 && gesture?.kind === 'pan') {
|
||||
const dx = event.touches[0].clientX - gesture.start.x;
|
||||
const dy = event.touches[0].clientY - gesture.start.y;
|
||||
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
|
||||
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
beginPan(event.touches[0]);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
if (!touchMoved && event.changedTouches.length > 0) {
|
||||
const now = performance.now();
|
||||
const touch = event.changedTouches[0];
|
||||
if (now - lastTapAt <= DOUBLE_TAP_MS) {
|
||||
if (scale > MIN_SCALE + 0.01) resetViewer();
|
||||
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
|
||||
lastTapAt = 0;
|
||||
} else {
|
||||
lastTapAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchCancel(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
touchMoved = false;
|
||||
applyTransform(scale, panX, panY);
|
||||
}
|
||||
|
||||
function attachTouchGestures(node: HTMLElement) {
|
||||
node.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
node.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
node.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('touchstart', handleTouchStart);
|
||||
node.removeEventListener('touchmove', handleTouchMove);
|
||||
node.removeEventListener('touchend', handleTouchEnd);
|
||||
node.removeEventListener('touchcancel', handleTouchCancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
closeButton?.focus();
|
||||
const handleResize = () => applyTransform(scale, panX, panY);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="image-viewer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
|
||||
tabindex="-1"
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onclose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="image-viewer-canvas"
|
||||
bind:this={canvasElement}
|
||||
use:attachTouchGestures
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
onkeydown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
bind:this={imageElement}
|
||||
class:interacting
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable="false"
|
||||
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
|
||||
onload={resetViewer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="image-viewer-reset"
|
||||
onclick={resetViewer}
|
||||
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
|
||||
aria-label={`Reset zoom, currently ${zoomPercent}%`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||
<path d="M3 4v6h6" />
|
||||
</svg>
|
||||
<span>{zoomPercent}%</span>
|
||||
</button>
|
||||
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
|
||||
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.image-viewer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: #06080c;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.image-viewer-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img {
|
||||
display: block;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
transition: transform 160ms ease-out;
|
||||
will-change: transform;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img.interacting {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls {
|
||||
position: absolute;
|
||||
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls button {
|
||||
min-height: 44px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(20, 23, 30, 0.86);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-viewer-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-viewer-reset:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.image-viewer-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.image-viewer-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 23, 30, 0.82);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image-viewer-canvas img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { showInfo, appConfig } from '$lib/stores/app';
|
||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import { showInfo } from '$lib/stores/app';
|
||||
import { openUrl } from '$lib/api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
import {
|
||||
ACTIONS,
|
||||
keybindings,
|
||||
@@ -84,13 +81,8 @@
|
||||
if (capturingId === id) stopCapture();
|
||||
}
|
||||
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
let appVersion = $state('...');
|
||||
let orphans = $state<OrphanAttachment[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let trashing = $state(false);
|
||||
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
|
||||
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||
|
||||
@@ -103,38 +95,7 @@
|
||||
openUrl(url).catch(console.error);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($showInfo) {
|
||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
||||
} else {
|
||||
stats = null;
|
||||
orphans = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function scanOrphans() {
|
||||
scanning = true;
|
||||
try { orphans = await findOrphanedAttachments(); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { scanning = false; }
|
||||
}
|
||||
async function trashOrphans() {
|
||||
if (!orphans || orphans.length === 0) return;
|
||||
trashing = true;
|
||||
try {
|
||||
await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
orphans = null;
|
||||
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { trashing = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $showInfo}
|
||||
@@ -144,7 +105,7 @@
|
||||
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="info-header">
|
||||
<h2>Info</h2>
|
||||
<button class="close-btn" onclick={close}>
|
||||
<button class="close-btn" onclick={close} aria-label="Close info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
@@ -176,49 +137,7 @@
|
||||
<p class="app-version">v{appVersion}</p>
|
||||
<p class="app-description">A local markdown note-taking app.</p>
|
||||
|
||||
{#if stats}
|
||||
<div class="info-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes</span>
|
||||
<span class="stat-value">{stats.total_notes}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments</span>
|
||||
<span class="stat-value">{stats.total_attachments}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Notes size</span>
|
||||
<span class="stat-value">{formatSize(stats.notes_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Attachments size</span>
|
||||
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
|
||||
</div>
|
||||
<div class="stat-row stat-total">
|
||||
<span class="stat-label">Total vault size</span>
|
||||
<span class="stat-value">{formatSize(stats.total_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stats}
|
||||
<div class="info-cleanup">
|
||||
{#if orphans === null}
|
||||
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
|
||||
{:else if orphans.length === 0}
|
||||
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphans as o}
|
||||
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info-credits">
|
||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||
@@ -273,6 +192,7 @@
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Collapsible section</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>.</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
@@ -416,39 +336,6 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.info-stats {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.stat-total .stat-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.info-credits {
|
||||
margin-top: 20px;
|
||||
@@ -480,71 +367,6 @@
|
||||
.info-link:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.info-cleanup {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cleanup-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.cleanup-msg {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.cleanup-list {
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cleanup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cleanup-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cleanup-size {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cleanup-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.spinner-icon {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.shortcuts-section {
|
||||
width: 100%;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
getNotes,
|
||||
readNote,
|
||||
createNote,
|
||||
duplicateNote,
|
||||
deleteNote,
|
||||
renameNote,
|
||||
saveNote,
|
||||
@@ -40,16 +41,18 @@
|
||||
} from '$lib/api';
|
||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||
import TasksView from './TasksView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import { isMobile, isAndroid } from '$lib/platform';
|
||||
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||
onNoteMoved?: () => void;
|
||||
onBeforeNoteSwitch?: () => void;
|
||||
onBeforeNoteDuplicate?: () => Promise<boolean>;
|
||||
onNoteCreated?: () => void;
|
||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||
@@ -265,6 +268,19 @@
|
||||
if (listContainer) listContainer.scrollTop = 0;
|
||||
});
|
||||
|
||||
// Notes can also be removed from this list by a drop handled in the sidebar.
|
||||
$effect(() => {
|
||||
if (selectedPaths.size === 0) return;
|
||||
const availablePaths = new Set($notes.map((note) => note.path));
|
||||
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
|
||||
if (remaining.size === selectedPaths.size) return;
|
||||
if (remaining.size === 0) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectedPaths = remaining;
|
||||
}
|
||||
});
|
||||
|
||||
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
|
||||
$effect(() => {
|
||||
$quickAccessPaths;
|
||||
@@ -454,6 +470,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
try {
|
||||
if ($activeNotePath === note.path && $editorDirty) {
|
||||
const saved = await onBeforeNoteDuplicate();
|
||||
if (!saved) {
|
||||
console.error('Failed to save note before duplicating');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await duplicateNote(note.path);
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
|
||||
await selectNote(entry);
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListDoubleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||
@@ -505,12 +542,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Move the currently open note to the trash (keyboard shortcut from the editor).
|
||||
export async function trashActiveNote() {
|
||||
if ($viewMode === 'trash' || !$activeNotePath) return;
|
||||
const note = $notes.find(n => n.path === $activeNotePath);
|
||||
if (note) await handleDelete(note);
|
||||
}
|
||||
|
||||
async function handleRestore(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
@@ -1259,11 +1290,10 @@
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
||||
} else {
|
||||
e.dataTransfer!.setData('text/plain', note.path);
|
||||
}
|
||||
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
|
||||
? selectedPaths
|
||||
: [note.path];
|
||||
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
@@ -1378,7 +1408,7 @@
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||
<!-- Batch context menu -->
|
||||
{#if $viewMode === 'trash'}
|
||||
@@ -1542,6 +1572,12 @@
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={() => handleDuplicate(contextMenu!.note)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
|
||||
</svg>
|
||||
Duplicate Note
|
||||
</button>
|
||||
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||
@@ -2284,13 +2320,15 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu {
|
||||
.context-menu.mobile {
|
||||
min-width: 220px;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
|
||||
import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import {
|
||||
buildNoteSwitcherRequestPaths,
|
||||
buildNoteSwitcherSections,
|
||||
type NoteSwitcherNote,
|
||||
type NoteSwitcherRow,
|
||||
type NoteSwitcherSections
|
||||
} from '$lib/utils/note-switcher';
|
||||
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
|
||||
|
||||
let { onSelectNote = async () => false }: {
|
||||
onSelectNote?: (path: string) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let wrapper: HTMLDivElement;
|
||||
let trigger: HTMLButtonElement;
|
||||
let popover = $state<HTMLDivElement | null>(null);
|
||||
let open = $state(false);
|
||||
let loading = $state(false);
|
||||
let selectingPath = $state<string | null>(null);
|
||||
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
||||
let loadGeneration = 0;
|
||||
let loadedVaultPath = $state<string | null | undefined>(undefined);
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isInsideVault(path: string, vaultPath: string): boolean {
|
||||
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
|
||||
}
|
||||
|
||||
function vaultRelativePath(path: string, vaultPath: string): string {
|
||||
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
|
||||
}
|
||||
|
||||
function joinVaultPath(vaultPath: string, relativePath: string): string {
|
||||
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
|
||||
const root = vaultPath.replace(/[\\/]+$/, '');
|
||||
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
|
||||
}
|
||||
|
||||
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!relativePath || relativePath.split('/').includes('..')) return null;
|
||||
const path = joinVaultPath(vaultPath, relativePath);
|
||||
if (!isInsideVault(path, vaultPath)) return null;
|
||||
return { path, title: entry.title, relativePath };
|
||||
}
|
||||
|
||||
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||
if (!isInsideVault(entry.path, vaultPath)) return null;
|
||||
return {
|
||||
path: entry.path,
|
||||
title: entry.meta.title,
|
||||
relativePath: entry.relative_path.replace(/\\/g, '/')
|
||||
};
|
||||
}
|
||||
|
||||
async function focusInitialRow() {
|
||||
await tick();
|
||||
if (!open) return;
|
||||
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
|
||||
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
|
||||
(current ?? first ?? popover)?.focus();
|
||||
}
|
||||
|
||||
async function refreshSections(generation: number) {
|
||||
const vaultPath = $appConfig?.active_vault;
|
||||
if (!vaultPath) {
|
||||
sections = { recent: [], quickAccess: [] };
|
||||
loadedVaultPath = null;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
|
||||
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
||||
getNoteSwitcherTitles(requestPaths),
|
||||
getQuickAccess()
|
||||
]);
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
|
||||
if (titlesResult.status === 'rejected') {
|
||||
console.error('Failed to load notes for note switcher:', titlesResult.reason);
|
||||
}
|
||||
if (quickAccessResult.status === 'rejected') {
|
||||
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
|
||||
}
|
||||
|
||||
const knownNotes = titlesResult.status === 'fulfilled'
|
||||
? titlesResult.value
|
||||
.map((entry) => titleEntryToNote(entry, vaultPath))
|
||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||
: [];
|
||||
const currentPath = $activeNotePath;
|
||||
if (currentPath && isInsideVault(currentPath, vaultPath)) {
|
||||
const currentKey = normalizedPath(currentPath);
|
||||
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
|
||||
knownNotes.push({
|
||||
path: currentPath,
|
||||
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
|
||||
relativePath: vaultRelativePath(currentPath, vaultPath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
|
||||
? quickAccessResult.value
|
||||
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||
: [];
|
||||
for (const note of quickAccessNotes) {
|
||||
if (!knownNotes.some((knownNote) => normalizedPath(knownNote.path) === normalizedPath(note.path))) {
|
||||
knownNotes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
sections = buildNoteSwitcherSections({
|
||||
currentPath,
|
||||
historyPaths: $navHistory.stack,
|
||||
knownNotes,
|
||||
quickAccessNotes
|
||||
});
|
||||
loadedVaultPath = vaultPath;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
}
|
||||
|
||||
function refreshAfterPopoverPaint(generation: number) {
|
||||
requestAnimationFrame(() => {
|
||||
window.setTimeout(() => {
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
void refreshSections(generation);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSwitcher() {
|
||||
if (open) {
|
||||
open = false;
|
||||
loadGeneration += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultPath = $appConfig?.active_vault ?? null;
|
||||
const hasCachedSections = loadedVaultPath === vaultPath;
|
||||
open = true;
|
||||
loading = !hasCachedSections;
|
||||
selectingPath = null;
|
||||
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
|
||||
loadGeneration += 1;
|
||||
const generation = loadGeneration;
|
||||
if (hasCachedSections) void focusInitialRow();
|
||||
refreshAfterPopoverPaint(generation);
|
||||
}
|
||||
|
||||
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
||||
open = false;
|
||||
loadGeneration += 1;
|
||||
if (restoreTriggerFocus) {
|
||||
await tick();
|
||||
trigger?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectNote(row: NoteSwitcherRow) {
|
||||
if (selectingPath !== null) return;
|
||||
selectingPath = row.path;
|
||||
try {
|
||||
if (await onSelectNote(row.path)) await closeSwitcher(false);
|
||||
} finally {
|
||||
selectingPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePopoverKeydown(event: KeyboardEvent) {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
|
||||
if (buttons.length === 0) return;
|
||||
event.preventDefault();
|
||||
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
|
||||
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
|
||||
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
|
||||
let nextIndex: number;
|
||||
if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = buttons.length - 1;
|
||||
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
|
||||
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
|
||||
buttons[nextIndex].focus();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const handleOutsideMouseDown = (event: MouseEvent) => {
|
||||
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void closeSwitcher(true);
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideMouseDown, true);
|
||||
document.addEventListener('keydown', handleEscape, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
|
||||
document.removeEventListener('keydown', handleEscape, true);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="note-switcher" bind:this={wrapper}>
|
||||
<button
|
||||
bind:this={trigger}
|
||||
class="switcher-trigger"
|
||||
type="button"
|
||||
aria-label="Switch note"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onclick={toggleSwitcher}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="7" y="4" width="12" height="14" rx="2" />
|
||||
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={popover}
|
||||
class="switcher-popover"
|
||||
role="dialog"
|
||||
aria-label="Note switcher"
|
||||
tabindex="-1"
|
||||
onkeydown={handlePopoverKeydown}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="switcher-status">Loading notes…</div>
|
||||
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
|
||||
<div class="switcher-status">No notes available</div>
|
||||
{:else}
|
||||
{#each [
|
||||
{ label: 'Recent', rows: sections.recent },
|
||||
{ label: 'Quick Access', rows: sections.quickAccess }
|
||||
] as section (section.label)}
|
||||
{#if section.rows.length > 0}
|
||||
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
|
||||
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
|
||||
{#each section.rows as row (row.path)}
|
||||
<div class="note-row" class:current-row={row.current}>
|
||||
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
|
||||
<button
|
||||
class="note-primary"
|
||||
type="button"
|
||||
aria-current={row.current ? 'true' : undefined}
|
||||
disabled={selectingPath !== null}
|
||||
onclick={() => selectNote(row)}
|
||||
>
|
||||
<span class="note-title">{row.title}</span>
|
||||
<span class="note-folder">{row.folder}</span>
|
||||
</button>
|
||||
<button
|
||||
class="open-window"
|
||||
type="button"
|
||||
aria-label={`Open ${row.title} in new window`}
|
||||
title={`Open ${row.title} in new window`}
|
||||
onclick={() => openNoteWindow(row.path, row.title)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="4" width="13" height="13" rx="2" />
|
||||
<path d="M10 14L20 4m-6 0h6v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-switcher {
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.switcher-trigger:hover,
|
||||
.switcher-trigger[aria-expanded='true'] {
|
||||
background: var(--bg-tertiary, var(--bg-hover));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.switcher-trigger:focus-visible,
|
||||
.note-primary:focus-visible,
|
||||
.open-window:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.switcher-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
max-height: min(420px, calc(100vh - 58px));
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher-section + .switcher-section {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
padding: 5px 8px 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.note-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.note-row:hover,
|
||||
.note-row:focus-within {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.note-row.current-row {
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.current-marker {
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.note-primary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 6px 38px 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.current-row .note-primary {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.note-primary:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.note-title,
|
||||
.note-folder {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.note-folder {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.open-window {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.note-row:hover .open-window,
|
||||
.note-row:focus-within .open-window {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.open-window:hover {
|
||||
background: var(--bg-tertiary, var(--bg-hover));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.switcher-status {
|
||||
padding: 22px 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import Editor from './Editor.svelte';
|
||||
import {
|
||||
appConfig,
|
||||
activeNote,
|
||||
activeNotePath,
|
||||
editorDirty,
|
||||
readOnly,
|
||||
sourceMode,
|
||||
theme
|
||||
sourceMode
|
||||
} from '$lib/stores/app';
|
||||
import { readNote } from '$lib/api';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
@@ -17,12 +18,12 @@
|
||||
|
||||
let { notePath }: { notePath: string } = $props();
|
||||
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const appWebview = getCurrentWebview();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
let editor = $state<Editor>(null!);
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
let unlistenUiScale: (() => void) | null = null;
|
||||
let maximized = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
@@ -36,20 +37,6 @@
|
||||
return () => { unlisten.then(fn => fn()); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const t = $theme || 'system';
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
});
|
||||
|
||||
let lastMouseDown = 0;
|
||||
const RESIZE_EDGE = 6;
|
||||
|
||||
@@ -84,7 +71,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUiScale(scale: number) {
|
||||
try {
|
||||
await appWebview.setZoom(scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply interface scale to note window:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
|
||||
void applyUiScale(event.payload);
|
||||
});
|
||||
await applyUiScale($appConfig?.ui_scale ?? 1);
|
||||
|
||||
try {
|
||||
const content = await readNote(notePath);
|
||||
$activeNote = content;
|
||||
@@ -116,6 +116,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
unlistenFileChange?.();
|
||||
unlistenUiScale?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { NotebookIconId } from '$lib/utils/notebook-icons';
|
||||
|
||||
let { icon, size = 18 }: { icon: NotebookIconId; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if icon === 'book'}
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
|
||||
{:else if icon === 'folder'}
|
||||
<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2.5h7.5A2.5 2.5 0 0 1 21 9v8.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z" />
|
||||
{:else if icon === 'briefcase'}
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v2h4v-2" />
|
||||
{:else if icon === 'home'}
|
||||
<path d="m3 11 9-8 9 8" />
|
||||
<path d="M5 10v11h14V10M9 21v-7h6v7" />
|
||||
{:else if icon === 'star'}
|
||||
<path d="m12 2 3.1 6.3 6.9 1-5 4.8 1.2 6.9-6.2-3.3L5.8 21 7 14.1l-5-4.8 6.9-1Z" />
|
||||
{:else if icon === 'heart'}
|
||||
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />
|
||||
{:else if icon === 'lightbulb'}
|
||||
<path d="M9 18h6M10 22h4" />
|
||||
<path d="M8.2 15.5A7 7 0 1 1 15.8 15.5c-.7.5-.8 1.1-.8 2.5H9c0-1.4-.1-2-.8-2.5Z" />
|
||||
{:else if icon === 'code'}
|
||||
<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />
|
||||
{:else if icon === 'flask'}
|
||||
<path d="M9 3h6M10 3v6l-5.5 9.5A1.7 1.7 0 0 0 6 21h12a1.7 1.7 0 0 0 1.5-2.5L14 9V3" />
|
||||
<path d="M7 16h10" />
|
||||
{:else if icon === 'graduation'}
|
||||
<path d="m2 9 10-5 10 5-10 5Z" />
|
||||
<path d="M6 11.5V16c3.5 2.7 8.5 2.7 12 0v-4.5M22 9v6" />
|
||||
{:else if icon === 'calendar'}
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" />
|
||||
{:else if icon === 'tasks'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="m7 8 1.5 1.5L11 7M13 9h4M7 14l1.5 1.5L11 13M13 15h4" />
|
||||
{:else if icon === 'archive'}
|
||||
<path d="M4 8v12h16V8M3 4h18v4H3ZM9 12h6" />
|
||||
{:else if icon === 'globe'}
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
{:else if icon === 'plane'}
|
||||
<path d="M22 2 9.5 14.5M22 2l-7 20-4-8-8-4Z" />
|
||||
{:else}
|
||||
<path d="M12 3a9 9 0 1 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a1.5 1.5 0 0 1 0-3h3a6 6 0 0 0 0-12Z" />
|
||||
<path d="M7.5 10h.01M9.5 6.5h.01M14.5 6.5h.01M17 10h.01" />
|
||||
{/if}
|
||||
</svg>
|
||||
@@ -1,17 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
||||
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { openUrl } from '$lib/api';
|
||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView, VaultStats } from '$lib/types';
|
||||
import { normalizeStartupView } from '$lib/utils/startup-view';
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
|
||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
|
||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'maintenance' | 'ai' | 'sync' | 'updates';
|
||||
let activeTab = $state<Tab>('styling');
|
||||
|
||||
// Updates state
|
||||
@@ -115,6 +117,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Vault maintenance state
|
||||
let vaultStats = $state<VaultStats | null>(null);
|
||||
let vaultStatsError = $state(false);
|
||||
let orphanedAttachments = $state<OrphanAttachment[] | null>(null);
|
||||
let scanningOrphanedAttachments = $state(false);
|
||||
let trashingOrphanedAttachments = $state(false);
|
||||
const orphanedAttachmentTotal = $derived((orphanedAttachments ?? []).reduce((total, attachment) => total + attachment.size, 0));
|
||||
|
||||
$effect(() => {
|
||||
if (!$showSettings) {
|
||||
vaultStats = null;
|
||||
vaultStatsError = false;
|
||||
orphanedAttachments = null;
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'maintenance') void loadVaultStats();
|
||||
});
|
||||
|
||||
async function loadVaultStats() {
|
||||
vaultStatsError = false;
|
||||
try {
|
||||
vaultStats = await getVaultStats();
|
||||
} catch (error) {
|
||||
vaultStats = null;
|
||||
vaultStatsError = true;
|
||||
console.error('Failed to load vault statistics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatVaultSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function scanOrphanedAttachments() {
|
||||
scanningOrphanedAttachments = true;
|
||||
try {
|
||||
orphanedAttachments = await findOrphanedAttachments();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
scanningOrphanedAttachments = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function trashFoundOrphanedAttachments() {
|
||||
if (!orphanedAttachments || orphanedAttachments.length === 0) return;
|
||||
trashingOrphanedAttachments = true;
|
||||
try {
|
||||
await trashOrphanedAttachments(orphanedAttachments.map((attachment) => attachment.name));
|
||||
orphanedAttachments = null;
|
||||
void loadVaultStats();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
trashingOrphanedAttachments = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Backup state
|
||||
let backups = $state<BackupEntry[]>([]);
|
||||
let backupLoading = $state(false);
|
||||
@@ -417,11 +480,12 @@
|
||||
}
|
||||
|
||||
let isThemeDark = $derived(
|
||||
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
|
||||
darkThemes.includes($resolvedTheme) ||
|
||||
($resolvedTheme.startsWith('custom-') && ($customThemes.find(c => c.id === $resolvedTheme)?.is_dark ?? false))
|
||||
);
|
||||
|
||||
const themePresets = [
|
||||
{ id: 'system', label: 'System', bg: '#ffffff', sidebar: '#1a1b26', accent: '#5b6abf' },
|
||||
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
|
||||
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
|
||||
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
|
||||
@@ -526,7 +590,45 @@
|
||||
let themeDropdownOpen = $state(false);
|
||||
let accentDropdownOpen = $state(false);
|
||||
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
|
||||
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
|
||||
let activeThemePreset = $derived(
|
||||
themePresets.find(p => p.id === $theme) ?? themePresets.find(p => p.id === 'light')!
|
||||
);
|
||||
|
||||
// Which theme each OS appearance maps to while `theme` is "system". Custom themes are offered
|
||||
// alongside the built-ins so a light/dark pair can be built out of them.
|
||||
let systemLightTheme = $state($appConfig?.system_light_theme ?? 'light');
|
||||
let systemDarkTheme = $state($appConfig?.system_dark_theme ?? 'dark');
|
||||
let systemPairOpen = $state<'light' | 'dark' | null>(null);
|
||||
|
||||
let pairPresets = $derived([
|
||||
...themePresets.filter(p => p.id !== 'system'),
|
||||
...$customThemes.map(ct => ({
|
||||
id: ct.id,
|
||||
label: ct.name,
|
||||
bg: ct.colors.bg_primary,
|
||||
sidebar: ct.colors.bg_secondary,
|
||||
accent: ct.colors.text_primary,
|
||||
})),
|
||||
]);
|
||||
|
||||
let systemPairRows = $derived([
|
||||
{ key: 'light' as const, title: 'Light appearance', selected: systemLightTheme },
|
||||
{ key: 'dark' as const, title: 'Dark appearance', selected: systemDarkTheme },
|
||||
]);
|
||||
|
||||
function selectSystemPairTheme(which: 'light' | 'dark', id: string) {
|
||||
if (which === 'light') systemLightTheme = id;
|
||||
else systemDarkTheme = id;
|
||||
systemPairOpen = null;
|
||||
if ($appConfig) {
|
||||
$appConfig.system_light_theme = systemLightTheme;
|
||||
$appConfig.system_dark_theme = systemDarkTheme;
|
||||
$appConfig = { ...$appConfig };
|
||||
}
|
||||
setSystemThemes(systemLightTheme, systemDarkTheme).catch((e) =>
|
||||
console.error('Failed to save system themes:', e)
|
||||
);
|
||||
}
|
||||
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
|
||||
let isCustomAccent = $derived(activeAccent.startsWith('#'));
|
||||
let activeAccentColor = $derived(
|
||||
@@ -552,7 +654,7 @@
|
||||
let customThemeImporting = $state(false);
|
||||
|
||||
function openNewCustomThemeEditor() {
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
|
||||
const isDark = isThemeDark;
|
||||
customThemeEditing = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: '',
|
||||
@@ -584,18 +686,20 @@
|
||||
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
|
||||
root.style.setProperty('--border-light', customThemeEditing.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', customThemeEditing.colors.text_secondary);
|
||||
root.classList.toggle('dark', customThemeEditing.is_dark);
|
||||
}
|
||||
|
||||
function restoreCurrentTheme() {
|
||||
const root = document.documentElement;
|
||||
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
|
||||
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color','--border-light','--text-tertiary'];
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
for (const v of varsToClear) root.style.removeProperty(v);
|
||||
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
|
||||
if ($theme.startsWith('custom-')) {
|
||||
const ct = $customThemes.find(c => c.id === $theme);
|
||||
if ($resolvedTheme.startsWith('custom-')) {
|
||||
const ct = $customThemes.find(c => c.id === $resolvedTheme);
|
||||
if (ct) {
|
||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||
@@ -606,12 +710,14 @@
|
||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
||||
root.style.setProperty('--border-light', ct.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
|
||||
if (ct.is_dark) root.classList.add('dark');
|
||||
}
|
||||
} else if (namedThemes.includes($theme)) {
|
||||
root.setAttribute('data-theme', $theme);
|
||||
if (darkThemes.includes($theme)) root.classList.add('dark');
|
||||
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (namedThemes.includes($resolvedTheme)) {
|
||||
root.setAttribute('data-theme', $resolvedTheme);
|
||||
if (darkThemes.includes($resolvedTheme)) root.classList.add('dark');
|
||||
} else if ($resolvedTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -646,9 +752,15 @@
|
||||
async function removeCustomTheme(ct: CustomTheme) {
|
||||
try {
|
||||
await deleteCustomTheme(ct.id);
|
||||
if (systemLightTheme === ct.id) systemLightTheme = 'light';
|
||||
if (systemDarkTheme === ct.id) systemDarkTheme = 'dark';
|
||||
if ($appConfig) {
|
||||
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
|
||||
$appConfig = { ...$appConfig };
|
||||
$appConfig = {
|
||||
...$appConfig,
|
||||
custom_themes: $appConfig.custom_themes.filter(t => t.id !== ct.id),
|
||||
system_light_theme: systemLightTheme,
|
||||
system_dark_theme: systemDarkTheme,
|
||||
};
|
||||
}
|
||||
if ($theme === ct.id) {
|
||||
$theme = 'system';
|
||||
@@ -700,6 +812,8 @@
|
||||
// General settings
|
||||
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
||||
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
||||
let showNoteSwitcher = $state($appConfig?.show_note_switcher ?? false);
|
||||
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
|
||||
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
|
||||
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
||||
let weekStart = $state($appConfig?.week_start ?? 'monday');
|
||||
@@ -715,6 +829,7 @@
|
||||
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
|
||||
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
|
||||
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
|
||||
let newNotesInSourceMode = $state($appConfig?.new_notes_in_source_mode ?? false);
|
||||
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
|
||||
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
||||
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
|
||||
@@ -773,6 +888,8 @@
|
||||
if ($appConfig) {
|
||||
$appConfig.compact_notes = compactNotes;
|
||||
$appConfig.show_note_dates = showNoteDates;
|
||||
$appConfig.show_note_switcher = showNoteSwitcher;
|
||||
$appConfig.startup_view = startupView;
|
||||
$appConfig.restore_last_session = restoreLastSession;
|
||||
$appConfig.time_format = timeFormat;
|
||||
$appConfig.week_start = weekStart;
|
||||
@@ -786,6 +903,7 @@
|
||||
$appConfig.show_line_numbers = showLineNumbers;
|
||||
$appConfig.show_link_arrows = showLinkArrows;
|
||||
$appConfig.default_view_mode = defaultViewMode;
|
||||
$appConfig.new_notes_in_source_mode = newNotesInSourceMode;
|
||||
$appConfig.show_tray_icon = showTrayIcon;
|
||||
$appConfig.close_to_tray = closeToTray;
|
||||
$appConfig.enable_wiki_links = enableWikiLinks;
|
||||
@@ -795,7 +913,7 @@
|
||||
$appConfig.show_daily_notes = showDailyNotes;
|
||||
$appConfig.show_trash = showTrash;
|
||||
}
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, showNoteSwitcher, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||
.catch((e) => console.error('Failed to save general settings:', e));
|
||||
}
|
||||
|
||||
@@ -807,7 +925,9 @@
|
||||
$theme = preset.id;
|
||||
setTheme(preset.id);
|
||||
themeDropdownOpen = false;
|
||||
selectCustomAccent(preset.accent);
|
||||
// "System" has no palette of its own, it defers to whichever paired theme is active, so it
|
||||
// must not overwrite the accent the way a concrete theme does.
|
||||
if (preset.id !== 'system') selectCustomAccent(preset.accent);
|
||||
}
|
||||
|
||||
function selectAccent(preset: typeof accentPresets[0]) {
|
||||
@@ -818,7 +938,7 @@
|
||||
|
||||
function applyAccent(preset: typeof accentPresets[0]) {
|
||||
const root = document.documentElement;
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDark = isThemeDark;
|
||||
const color = isDark ? preset.dark : preset.light;
|
||||
|
||||
root.style.setProperty('--accent', color);
|
||||
@@ -841,7 +961,7 @@
|
||||
|
||||
function applyCustomAccent(hex: string) {
|
||||
const root = document.documentElement;
|
||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDark = isThemeDark;
|
||||
root.style.setProperty('--accent', hex);
|
||||
root.style.setProperty('--text-accent', hex);
|
||||
root.style.setProperty('--accent-hover', hex);
|
||||
@@ -975,6 +1095,8 @@
|
||||
if ($appConfig) {
|
||||
compactNotes = $appConfig.compact_notes ?? false;
|
||||
showNoteDates = $appConfig.show_note_dates ?? true;
|
||||
showNoteSwitcher = $appConfig.show_note_switcher ?? false;
|
||||
startupView = normalizeStartupView($appConfig.startup_view);
|
||||
restoreLastSession = $appConfig.restore_last_session ?? false;
|
||||
timeFormat = $appConfig.time_format ?? 'relative';
|
||||
weekStart = $appConfig.week_start ?? 'monday';
|
||||
@@ -984,6 +1106,7 @@
|
||||
pdfPreview = $appConfig.pdf_preview ?? false;
|
||||
pdfHeight = $appConfig.pdf_height ?? 600;
|
||||
titleMode = $appConfig.title_mode ?? 'input';
|
||||
newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false;
|
||||
showAllNotes = $appConfig.show_all_notes ?? true;
|
||||
showQuickAccess = $appConfig.show_quick_access ?? true;
|
||||
showTasks = $appConfig.show_tasks ?? true;
|
||||
@@ -992,9 +1115,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Re-apply when theme changes
|
||||
// Re-apply when the concrete theme changes, including an OS appearance switch.
|
||||
$effect(() => {
|
||||
const _ = $theme;
|
||||
const _ = $resolvedTheme;
|
||||
const preset = accentPresets.find(p => p.name === activeAccent);
|
||||
if (preset) {
|
||||
applyAccent(preset);
|
||||
@@ -1053,6 +1176,12 @@
|
||||
Backup
|
||||
</button>
|
||||
{/if}
|
||||
<button class="tab-btn" class:active={activeTab === 'maintenance'} onclick={() => activeTab = 'maintenance'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
|
||||
</svg>
|
||||
Maintenance
|
||||
</button>
|
||||
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
|
||||
@@ -1151,6 +1280,44 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$platformIsMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Interface</h3>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Show note switcher in title bar</span>
|
||||
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showNoteSwitcher} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Startup</h3>
|
||||
<div class="setting-label">
|
||||
<span class="setting-name">Default view</span>
|
||||
<span class="setting-desc">Used when session restore is off or the previous view is unavailable.</span>
|
||||
</div>
|
||||
<div class="setting-options" style="margin-top: 8px;">
|
||||
<button class="option-btn" class:active={startupView === 'all'} onclick={() => { startupView = 'all'; saveGeneralSettings(); }}>All Notes</button>
|
||||
<button class="option-btn" class:active={startupView === 'quickaccess'} onclick={() => { startupView = 'quickaccess'; saveGeneralSettings(); }}>Quick Access</button>
|
||||
<button class="option-btn" class:active={startupView === 'tasks'} onclick={() => { startupView = 'tasks'; saveGeneralSettings(); }}>Tasks</button>
|
||||
<button class="option-btn" class:active={startupView === 'daily'} onclick={() => { startupView = 'daily'; saveGeneralSettings(); }}>Daily Notes</button>
|
||||
</div>
|
||||
<label class="setting-toggle" style="margin-top: 12px;">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Restore last session on launch</span>
|
||||
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Time Format</h3>
|
||||
<div class="setting-options">
|
||||
@@ -1192,6 +1359,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if !isMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Performance</h3>
|
||||
@@ -1240,6 +1408,78 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'maintenance'}
|
||||
<div class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>Vault Overview</h3>
|
||||
{#if vaultStats}
|
||||
<div class="maintenance-stats">
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Notes</span>
|
||||
<span class="maintenance-stat-value">{vaultStats.total_notes}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Attachments</span>
|
||||
<span class="maintenance-stat-value">{vaultStats.total_attachments}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Notes size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.notes_size)}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row">
|
||||
<span class="maintenance-stat-label">Attachments size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.attachments_size)}</span>
|
||||
</div>
|
||||
<div class="maintenance-stat-row maintenance-stat-total">
|
||||
<span class="maintenance-stat-label">Total vault size</span>
|
||||
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.total_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if vaultStatsError}
|
||||
<p class="setting-desc">Vault statistics could not be loaded.</p>
|
||||
{:else}
|
||||
<p class="setting-desc">Loading vault statistics…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Orphaned Attachments</h3>
|
||||
<div class="vault-maintenance">
|
||||
<p class="setting-desc">Find files in the vault's attachments folder that are not referenced by any note.</p>
|
||||
{#if orphanedAttachments === null}
|
||||
<button class="import-btn cleanup-action" onclick={scanOrphanedAttachments} disabled={scanningOrphanedAttachments}>
|
||||
{#if scanningOrphanedAttachments}
|
||||
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||
Scanning…
|
||||
{:else}
|
||||
Find orphaned attachments
|
||||
{/if}
|
||||
</button>
|
||||
{:else if orphanedAttachments.length === 0}
|
||||
<p class="cleanup-message">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-message">{orphanedAttachments.length} {orphanedAttachments.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatVaultSize(orphanedAttachmentTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphanedAttachments as attachment}
|
||||
<div class="cleanup-row">
|
||||
<span class="cleanup-name" title={attachment.name}>{attachment.name}</span>
|
||||
<span class="cleanup-size">{formatVaultSize(attachment.size)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="import-btn cleanup-action" onclick={trashFoundOrphanedAttachments} disabled={trashingOrphanedAttachments}>
|
||||
{#if trashingOrphanedAttachments}
|
||||
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||
Moving…
|
||||
{:else}
|
||||
Move {orphanedAttachments.length} to Trash
|
||||
{/if}
|
||||
</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'editor'}
|
||||
<div class="tab-content">
|
||||
<div class="settings-section">
|
||||
@@ -1282,17 +1522,18 @@
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<label class="setting-toggle" style="margin-top: 12px;">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Restore last session on launch</span>
|
||||
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</span>
|
||||
<span class="setting-name">Open new notes in source mode</span>
|
||||
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
||||
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Wiki Links & Graph</h3>
|
||||
<label class="setting-toggle">
|
||||
@@ -1384,6 +1625,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $theme === 'system'}
|
||||
<span class="setting-hint">Follows the operating system appearance.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
@@ -1432,6 +1676,54 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $theme === 'system'}
|
||||
{#each systemPairRows as row}
|
||||
<div class="settings-section">
|
||||
<h3>{row.title}</h3>
|
||||
<div class="dropdown-wrap">
|
||||
<button
|
||||
class="dropdown-trigger"
|
||||
class:open={systemPairOpen === row.key}
|
||||
onclick={() => { systemPairOpen = systemPairOpen === row.key ? null : row.key; themeDropdownOpen = false; accentDropdownOpen = false; }}
|
||||
>
|
||||
{#each pairPresets.filter(p => p.id === row.selected) as preset}
|
||||
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||
<span class="preview-sidebar"></span>
|
||||
<span class="preview-main">
|
||||
<span class="preview-accent-bar"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dropdown-trigger-label">{preset.label}</span>
|
||||
{/each}
|
||||
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
{#if systemPairOpen === row.key}
|
||||
<div class="dropdown-list" use:clickOutside={() => systemPairOpen = null}>
|
||||
{#each pairPresets as preset}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
class:active={row.selected === preset.id}
|
||||
onclick={() => selectSystemPairTheme(row.key, preset.id)}
|
||||
>
|
||||
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||
<span class="preview-sidebar"></span>
|
||||
<span class="preview-main">
|
||||
<span class="preview-accent-bar"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dropdown-item-label">{preset.label}</span>
|
||||
{#if row.selected === preset.id}
|
||||
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Custom Themes -->
|
||||
@@ -2811,6 +3103,91 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.maintenance-stats {
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.maintenance-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
|
||||
.maintenance-stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.maintenance-stat-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.maintenance-stat-total {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.maintenance-stat-total .maintenance-stat-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.vault-maintenance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cleanup-action {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cleanup-message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cleanup-list {
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cleanup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cleanup-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cleanup-size {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cleanup-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import type { NotebookEntry } from '$lib/types';
|
||||
import { isMobile } from '$lib/platform';
|
||||
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import NotebookGlyph from './NotebookGlyph.svelte';
|
||||
import {
|
||||
NOTEBOOK_ICON_OPTIONS,
|
||||
decodeBuiltinNotebookIcon,
|
||||
encodeBuiltinNotebookIcon,
|
||||
normalizeNotebookIconKey,
|
||||
type NotebookIconId
|
||||
} from '$lib/utils/notebook-icons';
|
||||
|
||||
let { onViewChanged = () => {} }: {
|
||||
onViewChanged?: () => void;
|
||||
@@ -78,6 +87,8 @@
|
||||
}
|
||||
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
|
||||
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
|
||||
let iconPickerNotebook = $state<NotebookEntry | null>(null);
|
||||
let iconPickerElement = $state<HTMLDivElement | null>(null);
|
||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let tagsCollapsed = $state(true);
|
||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||
@@ -340,22 +351,32 @@
|
||||
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
|
||||
e.preventDefault();
|
||||
dropTargetPath = null;
|
||||
const notePath = e.dataTransfer?.getData('text/plain');
|
||||
if (!notePath) return;
|
||||
// Don't move if already in this notebook
|
||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
||||
if (noteDir === nb.path) return;
|
||||
try {
|
||||
const newPath = await moveNote(notePath, nb.path);
|
||||
$notes = $notes.filter(n => n.path !== notePath);
|
||||
if ($activeNotePath === notePath) {
|
||||
$activeNotePath = newPath;
|
||||
$activeNote = await readNote(newPath);
|
||||
const payload = e.dataTransfer?.getData('text/plain') ?? '';
|
||||
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
|
||||
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
|
||||
if (notePaths.length === 0) return;
|
||||
|
||||
const movedPaths = new Map<string, string>();
|
||||
for (const notePath of notePaths) {
|
||||
try {
|
||||
movedPaths.set(notePath, await moveNote(notePath, nb.path));
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', notePath, e);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', e);
|
||||
}
|
||||
if (movedPaths.size === 0) return;
|
||||
|
||||
$notes = $notes.filter((note) => !movedPaths.has(note.path));
|
||||
const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined;
|
||||
if (activeNewPath) {
|
||||
$activeNotePath = activeNewPath;
|
||||
try {
|
||||
$activeNote = await readNote(activeNewPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to reload moved note:', e);
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||
@@ -558,21 +579,40 @@
|
||||
renameInput?.select();
|
||||
}
|
||||
|
||||
async function handleSetIcon(nb: NotebookEntry) {
|
||||
async function openIconPicker(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
iconPickerNotebook = nb;
|
||||
await tick();
|
||||
iconPickerElement?.focus();
|
||||
}
|
||||
|
||||
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
|
||||
try {
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
const value = encodeBuiltinNotebookIcon(icon);
|
||||
await setNotebookIcon(key, value);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: value };
|
||||
iconPickerNotebook = null;
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomIcon(nb: NotebookEntry) {
|
||||
iconPickerNotebook = null;
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
|
||||
});
|
||||
if (!selected) return;
|
||||
const filePath = typeof selected === 'string' ? selected : selected;
|
||||
// Read the file and save as attachment
|
||||
const data = await readFile(filePath as string);
|
||||
const fileName = (filePath as string).split('/').pop() || 'icon.png';
|
||||
const filePath = selected as string;
|
||||
const data = await readFile(filePath);
|
||||
const fileName = baseOf(filePath) || 'icon.png';
|
||||
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
|
||||
await setNotebookIcon(nb.relative_path, iconRelative);
|
||||
$notebookIcons = await getNotebookIcons();
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, iconRelative);
|
||||
$notebookIcons = { ...$notebookIcons, [key]: iconRelative };
|
||||
} catch (e) {
|
||||
console.error('Failed to set notebook icon:', e);
|
||||
}
|
||||
@@ -580,17 +620,21 @@
|
||||
|
||||
async function handleRemoveIcon(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
iconPickerNotebook = null;
|
||||
try {
|
||||
await setNotebookIcon(nb.relative_path, null);
|
||||
$notebookIcons = await getNotebookIcons();
|
||||
const key = normalizeNotebookIconKey(nb.relative_path);
|
||||
await setNotebookIcon(key, null);
|
||||
const icons = { ...$notebookIcons };
|
||||
delete icons[key];
|
||||
$notebookIcons = icons;
|
||||
} catch (e) {
|
||||
console.error('Failed to remove notebook icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getNotebookIconSrc(nb: NotebookEntry): string | null {
|
||||
const iconPath = $notebookIcons[nb.relative_path];
|
||||
if (!iconPath) return null;
|
||||
const iconPath = $notebookIcons[normalizeNotebookIconKey(nb.relative_path)];
|
||||
if (!iconPath || iconPath.startsWith('builtin:')) return null;
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot) return null;
|
||||
return convertFileSrc(`${vaultRoot}/${iconPath}`);
|
||||
@@ -610,6 +654,14 @@
|
||||
contextMenu = { x, y, notebook: nb };
|
||||
}
|
||||
|
||||
function openNotebookMenu(e: Event, nb: NotebookEntry) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
|
||||
contextMenu = { x, y, notebook: nb };
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
@@ -642,7 +694,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
|
||||
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
@@ -892,8 +944,11 @@
|
||||
</aside>
|
||||
|
||||
{#if contextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
|
||||
New Sub-notebook
|
||||
@@ -902,16 +957,10 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={() => handleSetIcon(contextMenu!.notebook)}>
|
||||
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
|
||||
Set Icon
|
||||
{$notebookIcons[normalizeNotebookIconKey(contextMenu.notebook.relative_path)] ? 'Change Icon...' : 'Set Icon...'}
|
||||
</button>
|
||||
{#if $notebookIcons[contextMenu.notebook.relative_path]}
|
||||
<button onclick={() => handleRemoveIcon(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
Remove Icon
|
||||
</button>
|
||||
{/if}
|
||||
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
|
||||
Delete
|
||||
@@ -919,9 +968,56 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if iconPickerNotebook}
|
||||
<div class="icon-picker-overlay" class:mobile={isMobile}>
|
||||
<button class="icon-picker-backdrop" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}></button>
|
||||
<div bind:this={iconPickerElement} class="icon-picker" role="dialog" aria-modal="true" aria-label={`Choose an icon for ${iconPickerNotebook.name}`} tabindex="-1">
|
||||
<header class="icon-picker-header">
|
||||
<div>
|
||||
<h3>Notebook icon</h3>
|
||||
<p>{iconPickerNotebook.name}</p>
|
||||
</div>
|
||||
<button class="icon-picker-close" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="icon-picker-grid">
|
||||
{#each NOTEBOOK_ICON_OPTIONS as option}
|
||||
<button
|
||||
class="icon-picker-option"
|
||||
class:active={$notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)] === encodeBuiltinNotebookIcon(option.id)}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
|
||||
>
|
||||
<NotebookGlyph icon={option.id} size={20} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="icon-picker-actions">
|
||||
<button onclick={() => handleCustomIcon(iconPickerNotebook!)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
|
||||
Custom image...
|
||||
</button>
|
||||
{#if $notebookIcons[normalizeNotebookIconKey(iconPickerNotebook.relative_path)]}
|
||||
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
|
||||
Use default
|
||||
</button>
|
||||
{/if}
|
||||
<p class="icon-picker-recommendation">Recommended size: <strong>1:1 aspect ratio</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if trashContextMenu}
|
||||
{#if isMobile}
|
||||
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
|
||||
<button class="danger" onclick={handleEmptyTrash}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
|
||||
@@ -935,7 +1031,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
||||
<h4>Delete "{deleteConfirm.name}"?</h4>
|
||||
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
||||
<div class="delete-confirm-actions">
|
||||
@@ -950,6 +1046,7 @@
|
||||
{@const hasChildren = nb.children.length > 0}
|
||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[normalizeNotebookIconKey(nb.relative_path)])}
|
||||
{#if editingNotebook === nb.path}
|
||||
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
||||
<input
|
||||
@@ -965,6 +1062,7 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="notebook-row" data-nb-path={nb.path}>
|
||||
<button
|
||||
class="notebook-item"
|
||||
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
||||
@@ -1037,7 +1135,9 @@
|
||||
{:else}
|
||||
<span class="chevron-spacer"></span>
|
||||
{/if}
|
||||
{#if iconSrc}
|
||||
{#if builtinIcon}
|
||||
<span class="notebook-builtin-icon"><NotebookGlyph icon={builtinIcon} size={18} /></span>
|
||||
{:else if iconSrc}
|
||||
<img class="notebook-icon" src={iconSrc} alt="" />
|
||||
{:else if isCollapsed || nb.children.length === 0}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
|
||||
@@ -1064,6 +1164,17 @@
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isMobile}
|
||||
<button
|
||||
type="button"
|
||||
class="notebook-actions-btn"
|
||||
aria-label={`Actions for ${nb.name}`}
|
||||
onclick={(e) => openNotebookMenu(e, nb)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasChildren && !isCollapsed}
|
||||
{#each nb.children as child (child.path)}
|
||||
@@ -1306,6 +1417,10 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.notebook-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notebook-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1378,6 +1493,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notebook-builtin-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notebook-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
@@ -1413,6 +1537,28 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.notebook-actions-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.notebook-actions-btn:active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -1481,6 +1627,15 @@
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.context-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1508,6 +1663,195 @@
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
|
||||
.icon-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.icon-picker-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(8, 10, 18, 0.42);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-picker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(360px, 100%);
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.icon-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.icon-picker-header h3,
|
||||
.icon-picker-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-picker-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.icon-picker-header p {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon-picker-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.icon-picker-option {
|
||||
min-width: 0;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 7px 3px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-option span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-picker-option:hover {
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-option.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-picker-option.active span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-picker-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 14px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.icon-picker-recommendation {
|
||||
flex: 0 0 100%;
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon-picker-actions button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-picker-actions button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-picker-actions button.remove {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile {
|
||||
place-items: end center;
|
||||
padding:
|
||||
20px
|
||||
calc(12px + env(safe-area-inset-right, 0px))
|
||||
calc(12px + env(safe-area-inset-bottom, 0px))
|
||||
calc(12px + env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker {
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker-option {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.icon-picker-overlay.mobile .icon-picker-actions button {
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.delete-confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1635,7 +1979,7 @@
|
||||
}
|
||||
|
||||
.sidebar.mobile .notebook-item {
|
||||
padding: 12px 12px;
|
||||
padding: 2px 56px 2px 12px;
|
||||
min-height: 48px;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
@@ -1646,6 +1990,11 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .notebook-builtin-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .new-notebook-input {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
@@ -1676,34 +2025,40 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile .context-menu {
|
||||
min-width: 200px;
|
||||
.context-menu.mobile {
|
||||
left: calc(12px + env(safe-area-inset-left, 0px)) !important;
|
||||
right: calc(12px + env(safe-area-inset-right, 0px));
|
||||
top: auto !important;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
min-width: 0;
|
||||
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm {
|
||||
.delete-confirm.mobile {
|
||||
max-width: calc(100vw - 40px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm h4 {
|
||||
.delete-confirm.mobile h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm p {
|
||||
.delete-confirm.mobile p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .delete-confirm-cancel,
|
||||
.sidebar.mobile .delete-confirm-btn {
|
||||
.delete-confirm.mobile .delete-confirm-cancel,
|
||||
.delete-confirm.mobile .delete-confirm-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
min-height: 44px;
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
|
||||
import { syncNow } from '$lib/api';
|
||||
import NoteSwitcher from './NoteSwitcher.svelte';
|
||||
|
||||
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
||||
let {
|
||||
onNewNote = () => {},
|
||||
onDailyNote = () => {},
|
||||
onSelectNote
|
||||
}: {
|
||||
onNewNote?: () => void;
|
||||
onDailyNote?: () => void;
|
||||
onSelectNote?: (path: string) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
@@ -30,7 +36,7 @@
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
|
||||
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions') || target.closest('.titlebar-note-switcher')) return;
|
||||
|
||||
// Don't start dragging near window edges - let Tauri handle resize
|
||||
if (!maximized) {
|
||||
@@ -81,6 +87,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $appConfig?.show_note_switcher}
|
||||
<div class="titlebar-note-switcher">
|
||||
<NoteSwitcher {onSelectNote} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="titlebar-actions">
|
||||
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -173,7 +184,13 @@
|
||||
gap: 8px;
|
||||
padding-left: 14px;
|
||||
pointer-events: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.titlebar-note-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.titlebar-title {
|
||||
@@ -205,6 +222,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Schema } from '@tiptap/pm/model';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
import { liftTarget } from '@tiptap/pm/transform';
|
||||
|
||||
// Marks that carry content (URLs, note references) rather than visual
|
||||
// formatting. Clearing formatting must never destroy these.
|
||||
const CONTENT_MARKS: Record<string, true> = { link: true, wikiLink: true };
|
||||
|
||||
// Block types reset to plain paragraphs. Lists, callouts, collapsible
|
||||
// sections and tables keep their structure so no state (checkboxes,
|
||||
// nesting, cells) is lost.
|
||||
const DEMOTE_BLOCKS: Record<string, true> = { heading: true, codeBlock: true };
|
||||
|
||||
// Outermost-first position of the first blockquote intersecting [from, to].
|
||||
function findBlockquote(tr: Transaction, from: number, to: number): number | null {
|
||||
let found: number | null = null;
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (found !== null) return false;
|
||||
if (node.type.name === 'blockquote') {
|
||||
found = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips inline formatting and demotes heading/code-block/blockquote
|
||||
* structure over the transaction's selection. An empty (cursor) selection
|
||||
* is expanded to the whole current text block, so a single invocation can
|
||||
* reset one messy line without highlighting it first.
|
||||
*/
|
||||
export function applyClearFormatting(schema: Schema, tr: Transaction): void {
|
||||
const { selection } = tr;
|
||||
const { $from } = selection;
|
||||
const expandToBlock = selection.empty && $from.parent.isTextblock;
|
||||
const from = expandToBlock ? $from.start() : selection.from;
|
||||
const to = expandToBlock ? $from.end() : selection.to;
|
||||
|
||||
// 1. Strip formatting marks, keep content-bearing ones.
|
||||
for (const mark of Object.values(schema.marks)) {
|
||||
if (!CONTENT_MARKS[mark.name]) tr.removeMark(from, to, mark);
|
||||
}
|
||||
|
||||
// 2. Demote headings and code blocks to paragraphs. Mark removal and
|
||||
// retyping never move content, so the original positions stay valid.
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (DEMOTE_BLOCKS[node.type.name]) tr.setNodeMarkup(pos, schema.nodes.paragraph);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3. Lift blockquote contents out of the quote wrapper, one pass per
|
||||
// nesting level. Lifts move positions, so remap and rescan each pass.
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const quotePos = findBlockquote(tr, tr.mapping.map(from, 1), tr.mapping.map(to, -1));
|
||||
if (quotePos === null) break;
|
||||
// The range must span the blockquote's content: a collapsed
|
||||
// blockRange starts one depth up and wraps the quote itself.
|
||||
const quote = tr.doc.nodeAt(quotePos);
|
||||
if (!quote) break;
|
||||
const range = tr.doc.resolve(quotePos + 1).blockRange(tr.doc.resolve(quotePos + quote.nodeSize - 1));
|
||||
if (!range) break;
|
||||
const target = liftTarget(range);
|
||||
if (target === null) break;
|
||||
tr.lift(range, target);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears formatting for the editor's current selection (or current line). */
|
||||
export function clearFormatting(editor: Editor): boolean {
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ state, tr }) => {
|
||||
applyClearFormatting(state.schema, tr);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
const CODE_FENCE_PATTERN = /^(?:```|~~~)(?:[a-z]+)?$/;
|
||||
|
||||
function isInputRulesPlugin(plugin: Plugin) {
|
||||
return 'isInputRules' in plugin.spec && plugin.spec.isInputRules === true;
|
||||
}
|
||||
|
||||
export function createCodeBlockInputScrollPlugin() {
|
||||
return new Plugin({
|
||||
key: new PluginKey('codeBlockInputScroll'),
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
const inputRulesPlugin = oldState.plugins.find(isInputRulesPlugin);
|
||||
const wasKeyboardInputRule =
|
||||
inputRulesPlugin !== undefined &&
|
||||
transactions.some((transaction) => {
|
||||
const metadata: unknown = transaction.getMeta(inputRulesPlugin);
|
||||
|
||||
return (
|
||||
typeof metadata === 'object' &&
|
||||
metadata !== null &&
|
||||
'transform' in metadata &&
|
||||
metadata.transform === transaction &&
|
||||
'text' in metadata &&
|
||||
metadata.text === '\n'
|
||||
);
|
||||
});
|
||||
const { selection } = oldState;
|
||||
const wasFenceParagraph =
|
||||
selection.empty &&
|
||||
selection.$from.parent.type.name === 'paragraph' &&
|
||||
CODE_FENCE_PATTERN.test(selection.$from.parent.textContent);
|
||||
const isCodeBlock = newState.selection.$from.parent.type.name === 'codeBlock';
|
||||
|
||||
return wasKeyboardInputRule && wasFenceParagraph && isCodeBlock
|
||||
? newState.tr.scrollIntoView()
|
||||
: null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const CodeBlockInputScroll = Extension.create({
|
||||
name: 'codeBlockInputScroll',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [createCodeBlockInputScrollPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
|
||||
type AssetSourceNormalizer = (source: string) => string;
|
||||
|
||||
type MarkdownMark = {
|
||||
key: string;
|
||||
priority: number;
|
||||
open: string;
|
||||
close: string;
|
||||
};
|
||||
|
||||
const identityAssetSource: AssetSourceNormalizer = (source) => source;
|
||||
|
||||
function stringAttr(mark: Mark, name: string): string {
|
||||
const value = mark.attrs?.[name];
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function markdownMark(mark: Mark, text: string): MarkdownMark | null {
|
||||
switch (mark.type.name) {
|
||||
case 'bold':
|
||||
return { key: 'bold', priority: 10, open: '**', close: '**' };
|
||||
case 'italic':
|
||||
return { key: 'italic', priority: 20, open: '*', close: '*' };
|
||||
case 'strike':
|
||||
return { key: 'strike', priority: 30, open: '~~', close: '~~' };
|
||||
case 'underline':
|
||||
return { key: 'underline', priority: 40, open: '<u>', close: '</u>' };
|
||||
case 'subscript':
|
||||
return { key: 'subscript', priority: 50, open: '~', close: '~' };
|
||||
case 'superscript':
|
||||
return { key: 'superscript', priority: 60, open: '^', close: '^' };
|
||||
case 'highlight': {
|
||||
const color = stringAttr(mark, 'color');
|
||||
return color
|
||||
? {
|
||||
key: `highlight:${color}`,
|
||||
priority: 70,
|
||||
open: `<mark data-color="${color}">`,
|
||||
close: '</mark>',
|
||||
}
|
||||
: { key: 'highlight', priority: 70, open: '==', close: '==' };
|
||||
}
|
||||
case 'textStyle': {
|
||||
const color = stringAttr(mark, 'color');
|
||||
return color
|
||||
? {
|
||||
key: `textStyle:${color}`,
|
||||
priority: 80,
|
||||
open: `<span style="color: ${color}">`,
|
||||
close: '</span>',
|
||||
}
|
||||
: null;
|
||||
}
|
||||
case 'code':
|
||||
return { key: 'code', priority: 90, open: '`', close: '`' };
|
||||
case 'link': {
|
||||
const href = stringAttr(mark, 'href');
|
||||
return {
|
||||
key: `link:${href}`,
|
||||
priority: 100,
|
||||
open: '[',
|
||||
close: `](${href})`,
|
||||
};
|
||||
}
|
||||
case 'wikiLink': {
|
||||
const title = stringAttr(mark, 'title') || text;
|
||||
const aliased = mark.attrs?.aliased === true || title !== text;
|
||||
return {
|
||||
key: `wikiLink:${title}:${aliased}`,
|
||||
priority: 110,
|
||||
open: aliased ? `[[${title}|` : '[[',
|
||||
close: ']]',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function marksForText(node: ProseMirrorNode): MarkdownMark[] {
|
||||
return node.marks
|
||||
.map((mark) => markdownMark(mark, node.text || ''))
|
||||
.filter((mark): mark is MarkdownMark => mark !== null)
|
||||
.sort((left, right) => left.priority - right.priority || left.key.localeCompare(right.key));
|
||||
}
|
||||
|
||||
function commonMarkCount(active: MarkdownMark[], next: MarkdownMark[]): number {
|
||||
const length = Math.min(active.length, next.length);
|
||||
let index = 0;
|
||||
while (index < length && active[index].key === next[index].key) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
export function serializeInlineMarkdown(
|
||||
node: ProseMirrorNode,
|
||||
normalizeAssetSource: AssetSourceNormalizer = identityAssetSource,
|
||||
): string {
|
||||
let result = '';
|
||||
let activeMarks: MarkdownMark[] = [];
|
||||
|
||||
const transitionMarks = (nextMarks: MarkdownMark[]) => {
|
||||
const shared = commonMarkCount(activeMarks, nextMarks);
|
||||
for (let index = activeMarks.length - 1; index >= shared; index -= 1) {
|
||||
result += activeMarks[index].close;
|
||||
}
|
||||
for (let index = shared; index < nextMarks.length; index += 1) {
|
||||
result += nextMarks[index].open;
|
||||
}
|
||||
activeMarks = nextMarks;
|
||||
};
|
||||
|
||||
node.forEach((child, _offset, index) => {
|
||||
if (child.isText) {
|
||||
transitionMarks(marksForText(child));
|
||||
let text = child.text || '';
|
||||
if (index === 0) {
|
||||
text = text.replace(/^[\t\u2003]+/, (whitespace) => ' '.repeat(whitespace.length));
|
||||
}
|
||||
result += text;
|
||||
return;
|
||||
}
|
||||
|
||||
transitionMarks([]);
|
||||
|
||||
if (child.type.name === 'image') {
|
||||
const source = normalizeAssetSource(child.attrs.src || '');
|
||||
if (!source) return;
|
||||
const alt = child.attrs.alt || '';
|
||||
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
||||
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
||||
if (result) result += '\n';
|
||||
result += ``;
|
||||
} else if (child.type.name === 'mathInline') {
|
||||
result += `$${child.attrs.tex || ''}$`;
|
||||
} else if (child.type.name === 'hardBreak') {
|
||||
result += ' \n';
|
||||
}
|
||||
});
|
||||
|
||||
transitionMarks([]);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Node as ProseMirrorNode, Schema } from '@tiptap/pm/model';
|
||||
|
||||
export type MixedListName = 'bulletList' | 'taskList';
|
||||
|
||||
export function convertListNode(
|
||||
schema: Schema,
|
||||
listNode: ProseMirrorNode,
|
||||
targetListName: MixedListName,
|
||||
): ProseMirrorNode | null {
|
||||
if (
|
||||
(listNode.type.name !== 'bulletList' && listNode.type.name !== 'taskList') ||
|
||||
listNode.type.name === targetListName
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetListType = schema.nodes[targetListName];
|
||||
const targetItemType = schema.nodes[targetListName === 'taskList' ? 'taskItem' : 'listItem'];
|
||||
if (!targetListType || !targetItemType) return null;
|
||||
|
||||
const itemAttrs = targetListName === 'taskList' ? { checked: false } : null;
|
||||
const convertedItems = listNode.content.content.map((item) =>
|
||||
targetItemType.create(itemAttrs, item.content, item.marks)
|
||||
);
|
||||
|
||||
return targetListType.create(null, convertedItems, listNode.marks);
|
||||
}
|
||||
+25
-1
@@ -1,5 +1,6 @@
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { isMobile } from "$lib/platform";
|
||||
import { compareNaturalNames } from "$lib/utils/natural-sort";
|
||||
import type {
|
||||
AppConfig,
|
||||
CustomTheme,
|
||||
@@ -89,6 +90,29 @@ export const readOnly = writable(false);
|
||||
export const theme = writable<string>("system");
|
||||
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
|
||||
|
||||
// Whether the OS is currently in dark mode. Held in a store (rather than read at each call site)
|
||||
// so the theme re-resolves when the user flips appearance while the app is open.
|
||||
const darkQuery =
|
||||
typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
||||
export const systemPrefersDark = writable<boolean>(darkQuery?.matches ?? false);
|
||||
darkQuery?.addEventListener("change", (e) => systemPrefersDark.set(e.matches));
|
||||
|
||||
// The theme to actually render: "system" resolves to the configured light/dark pair, everything
|
||||
// else passes through. Falls back to the plain schemes when no pair is configured yet.
|
||||
export const resolvedTheme = derived(
|
||||
[theme, appConfig, systemPrefersDark],
|
||||
([$theme, $config, $prefersDark]): string => {
|
||||
if ($theme !== "system") return $theme;
|
||||
const fallback = $prefersDark ? "dark" : "light";
|
||||
const paired = $prefersDark ? $config?.system_dark_theme : $config?.system_light_theme;
|
||||
if (!paired) return fallback;
|
||||
if (paired.startsWith("custom-") && !$config?.custom_themes.some((item) => item.id === paired)) {
|
||||
return fallback;
|
||||
}
|
||||
return paired;
|
||||
},
|
||||
);
|
||||
|
||||
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
||||
// whoever triggered it (manual button, settings, interval, on-change).
|
||||
export const syncState = writable<{ running: boolean; error: string | null }>({
|
||||
@@ -216,7 +240,7 @@ export const sortedNotes = derived(
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
}
|
||||
case "title":
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
return compareNaturalNames(a.meta.title, b.meta.title);
|
||||
case "created":
|
||||
return (
|
||||
new Date(b.meta.created).getTime() -
|
||||
|
||||
@@ -81,11 +81,15 @@ export interface CustomTheme {
|
||||
colors: CustomThemeColors;
|
||||
}
|
||||
|
||||
export type StartupView = "all" | "quickaccess" | "tasks" | "daily";
|
||||
|
||||
export interface AppConfig {
|
||||
vaults: VaultConfig[];
|
||||
active_vault: string | null;
|
||||
active_bookmark_id?: string | null;
|
||||
theme: string;
|
||||
system_light_theme: string;
|
||||
system_dark_theme: string;
|
||||
accent_color: string | null;
|
||||
font_size: number | null;
|
||||
font_family: string | null;
|
||||
@@ -94,6 +98,7 @@ export interface AppConfig {
|
||||
content_width: number | null;
|
||||
compact_notes: boolean;
|
||||
show_note_dates: boolean;
|
||||
show_note_switcher: boolean;
|
||||
time_format: string;
|
||||
week_start: string;
|
||||
daily_title_format: string;
|
||||
@@ -127,9 +132,11 @@ export interface AppConfig {
|
||||
ai_model: string;
|
||||
ai_writing_style: string | null;
|
||||
default_view_mode: boolean;
|
||||
new_notes_in_source_mode: boolean;
|
||||
show_tray_icon: boolean;
|
||||
close_to_tray: boolean;
|
||||
enable_wiki_links: boolean;
|
||||
startup_view: StartupView;
|
||||
restore_last_session: boolean;
|
||||
sync_provider: string | null;
|
||||
webdav_url: string | null;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const naturalNameCollator = new Intl.Collator(undefined, { numeric: true });
|
||||
|
||||
export function compareNaturalNames(left: string, right: string): number {
|
||||
return naturalNameCollator.compare(left, right);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function encodeNoteDragPaths(paths: Iterable<string>): string {
|
||||
return [...paths].join("\n");
|
||||
}
|
||||
|
||||
export function decodeNoteDragPaths(payload: string): string[] {
|
||||
return payload.split(/\r?\n/).filter((path) => path.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export interface NoteSwitcherNote {
|
||||
path: string;
|
||||
title: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface NoteSwitcherRow {
|
||||
path: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface NoteSwitcherSections {
|
||||
recent: NoteSwitcherRow[];
|
||||
quickAccess: NoteSwitcherRow[];
|
||||
}
|
||||
|
||||
interface BuildNoteSwitcherSectionsOptions {
|
||||
currentPath: string | null;
|
||||
historyPaths: readonly string[];
|
||||
knownNotes: readonly NoteSwitcherNote[];
|
||||
quickAccessNotes: readonly NoteSwitcherNote[];
|
||||
recentLimit?: number;
|
||||
}
|
||||
|
||||
function pathKey(path: string): string {
|
||||
return path.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function buildNoteSwitcherRequestPaths(
|
||||
currentPath: string | null,
|
||||
historyPaths: readonly string[]
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addPath = (path: string | null) => {
|
||||
if (!path) return;
|
||||
const key = pathKey(path);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
paths.push(path);
|
||||
};
|
||||
|
||||
addPath(currentPath);
|
||||
for (let index = historyPaths.length - 1; index >= 0; index -= 1) {
|
||||
addPath(historyPaths[index]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function folderLabel(relativePath: string): string {
|
||||
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.join('/') || 'Unfiled';
|
||||
}
|
||||
|
||||
function toRow(note: NoteSwitcherNote, currentPathKey: string | null): NoteSwitcherRow {
|
||||
return {
|
||||
path: note.path,
|
||||
title: note.title,
|
||||
folder: folderLabel(note.relativePath),
|
||||
current: currentPathKey !== null && pathKey(note.path) === currentPathKey
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNoteSwitcherSections({
|
||||
currentPath,
|
||||
historyPaths,
|
||||
knownNotes,
|
||||
quickAccessNotes,
|
||||
recentLimit = 6
|
||||
}: BuildNoteSwitcherSectionsOptions): NoteSwitcherSections {
|
||||
const knownByPath = new Map(knownNotes.map((note) => [pathKey(note.path), note]));
|
||||
const currentPathKey = currentPath ? pathKey(currentPath) : null;
|
||||
const recent: NoteSwitcherRow[] = [];
|
||||
const shownPaths = new Set<string>();
|
||||
const limit = Math.max(0, Math.floor(recentLimit));
|
||||
|
||||
const addRecent = (path: string | null) => {
|
||||
if (!path || recent.length >= limit) return;
|
||||
const key = pathKey(path);
|
||||
if (shownPaths.has(key)) return;
|
||||
const note = knownByPath.get(key);
|
||||
if (!note) return;
|
||||
shownPaths.add(key);
|
||||
recent.push(toRow(note, currentPathKey));
|
||||
};
|
||||
|
||||
for (const path of buildNoteSwitcherRequestPaths(currentPath, historyPaths)) {
|
||||
if (recent.length >= limit) break;
|
||||
addRecent(path);
|
||||
}
|
||||
|
||||
const quickAccess: NoteSwitcherRow[] = [];
|
||||
for (const note of quickAccessNotes) {
|
||||
const key = pathKey(note.path);
|
||||
if (shownPaths.has(key) || !knownByPath.has(key)) continue;
|
||||
shownPaths.add(key);
|
||||
quickAccess.push(toRow(note, currentPathKey));
|
||||
}
|
||||
|
||||
return { recent, quickAccess };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const NOTEBOOK_ICON_OPTIONS = [
|
||||
{ id: 'book', label: 'Book' },
|
||||
{ id: 'folder', label: 'Folder' },
|
||||
{ id: 'briefcase', label: 'Work' },
|
||||
{ id: 'home', label: 'Home' },
|
||||
{ id: 'star', label: 'Star' },
|
||||
{ id: 'heart', label: 'Heart' },
|
||||
{ id: 'lightbulb', label: 'Ideas' },
|
||||
{ id: 'code', label: 'Code' },
|
||||
{ id: 'flask', label: 'Research' },
|
||||
{ id: 'graduation', label: 'Study' },
|
||||
{ id: 'calendar', label: 'Calendar' },
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
{ id: 'archive', label: 'Archive' },
|
||||
{ id: 'globe', label: 'Globe' },
|
||||
{ id: 'plane', label: 'Travel' },
|
||||
{ id: 'palette', label: 'Creative' }
|
||||
] as const;
|
||||
|
||||
export type NotebookIconId = (typeof NOTEBOOK_ICON_OPTIONS)[number]['id'];
|
||||
|
||||
const BUILTIN_PREFIX = 'builtin:';
|
||||
const BUILTIN_IDS: Record<NotebookIconId, true> = {
|
||||
book: true,
|
||||
folder: true,
|
||||
briefcase: true,
|
||||
home: true,
|
||||
star: true,
|
||||
heart: true,
|
||||
lightbulb: true,
|
||||
code: true,
|
||||
flask: true,
|
||||
graduation: true,
|
||||
calendar: true,
|
||||
tasks: true,
|
||||
archive: true,
|
||||
globe: true,
|
||||
plane: true,
|
||||
palette: true
|
||||
};
|
||||
|
||||
export function normalizeNotebookIconKey(path: string): string {
|
||||
return path.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
|
||||
return `${BUILTIN_PREFIX}${icon}`;
|
||||
}
|
||||
|
||||
export function decodeBuiltinNotebookIcon(value: string | null | undefined): NotebookIconId | null {
|
||||
if (!value?.startsWith(BUILTIN_PREFIX)) return null;
|
||||
const icon = value.slice(BUILTIN_PREFIX.length);
|
||||
return Object.hasOwn(BUILTIN_IDS, icon) ? icon as NotebookIconId : null;
|
||||
}
|
||||
@@ -26,3 +26,40 @@ export function relativePath(fromDirectory: string, targetPath: string): string
|
||||
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
|
||||
return result.join('/') || '.';
|
||||
}
|
||||
|
||||
export function resolvePathFromFile(filePath: string, targetPath: string): string {
|
||||
const normalizedFile = filePath.replace(/\\/g, '/');
|
||||
const normalizedTarget = targetPath.replace(/\\/g, '/');
|
||||
const lastSeparator = normalizedFile.lastIndexOf('/');
|
||||
const fileDirectory = lastSeparator >= 0 ? normalizedFile.slice(0, lastSeparator) : '';
|
||||
const combined = normalizedTarget.startsWith('/') || /^[A-Za-z]:\//.test(normalizedTarget)
|
||||
? normalizedTarget
|
||||
: `${fileDirectory}/${normalizedTarget}`;
|
||||
const drive = combined.match(/^([A-Za-z]:)\//);
|
||||
const unc = combined.startsWith('//');
|
||||
const absolute = !unc && combined.startsWith('/');
|
||||
let prefix = '';
|
||||
let remainder = combined;
|
||||
if (drive) {
|
||||
prefix = `${drive[1]}/`;
|
||||
remainder = combined.slice(drive[0].length);
|
||||
} else if (unc) {
|
||||
prefix = '//';
|
||||
remainder = combined.slice(2);
|
||||
} else if (absolute) {
|
||||
prefix = '/';
|
||||
remainder = combined.slice(1);
|
||||
}
|
||||
const resolved: string[] = [];
|
||||
|
||||
for (const segment of remainder.split('/')) {
|
||||
if (!segment || segment === '.') continue;
|
||||
if (segment === '..') {
|
||||
resolved.pop();
|
||||
} else {
|
||||
resolved.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + resolved.join('/');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { StartupView } from "../types";
|
||||
|
||||
type RestorableListView = StartupView | "trash";
|
||||
|
||||
export type StartupTarget =
|
||||
| { mode: RestorableListView }
|
||||
| { mode: "notebook"; notebookPath: string }
|
||||
| { mode: "tag"; tag: string };
|
||||
|
||||
export interface StartupState {
|
||||
startupView: unknown;
|
||||
restoreLastSession: boolean;
|
||||
lastViewMode: unknown;
|
||||
lastNotebook: string | null;
|
||||
lastTag: string | null;
|
||||
}
|
||||
|
||||
export function normalizeStartupView(value: unknown): StartupView {
|
||||
switch (value) {
|
||||
case "daily":
|
||||
case "quickaccess":
|
||||
case "tasks":
|
||||
return value;
|
||||
default:
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
function restorableListView(value: unknown): RestorableListView | null {
|
||||
switch (value) {
|
||||
case "all":
|
||||
case "daily":
|
||||
case "quickaccess":
|
||||
case "tasks":
|
||||
case "trash":
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveStartupTarget(state: StartupState): StartupTarget {
|
||||
const fallback: StartupTarget = {
|
||||
mode: normalizeStartupView(state.startupView),
|
||||
};
|
||||
if (!state.restoreLastSession) return fallback;
|
||||
|
||||
if (state.lastViewMode === "notebook" && typeof state.lastNotebook === "string") {
|
||||
return { mode: "notebook", notebookPath: state.lastNotebook };
|
||||
}
|
||||
if (state.lastViewMode === "tag" && state.lastTag) {
|
||||
return { mode: "tag", tag: state.lastTag };
|
||||
}
|
||||
|
||||
const listView = restorableListView(state.lastViewMode);
|
||||
return listView ? { mode: listView } : fallback;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { resolvedTheme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
|
||||
import { get } from 'svelte/store';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
@@ -10,9 +10,11 @@
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Reactively apply theme class to <html> whenever $theme or custom themes change
|
||||
// Reactively apply theme class to <html> whenever the resolved theme or custom themes change.
|
||||
// $resolvedTheme already maps "system" onto the configured light/dark pair, so flipping the OS
|
||||
// appearance re-runs this effect.
|
||||
$effect(() => {
|
||||
applyTheme($theme, $customThemes);
|
||||
applyTheme($resolvedTheme, $customThemes);
|
||||
});
|
||||
|
||||
// Apply link arrow visibility from config
|
||||
@@ -26,8 +28,13 @@
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary',
|
||||
'--bg-hover', '--bg-active', '--bg-editor',
|
||||
'--text-primary', '--text-secondary', '--border-color',
|
||||
'--border-light', '--text-tertiary',
|
||||
];
|
||||
|
||||
// A custom theme carries nine colors, but the stylesheet also reads --border-light and
|
||||
// --text-tertiary. Named themes set those in their own :root[data-theme] block; a custom theme
|
||||
// has no such block, so without these they fall back to the light defaults in app.css and a dark
|
||||
// custom theme draws near-white dividers. Derive them from the closest color the theme does have.
|
||||
function applyCustomThemeVars(root: HTMLElement, ct: CustomTheme) {
|
||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||
@@ -38,6 +45,8 @@
|
||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
||||
root.style.setProperty('--border-light', ct.colors.border_color);
|
||||
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
|
||||
}
|
||||
|
||||
function clearCustomThemeVars(root: HTMLElement) {
|
||||
|
||||
+47
-8
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes, isIOS } from '$lib/platform';
|
||||
import { darkThemes, isIOS, isMobile } from '$lib/platform';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
||||
@@ -15,6 +16,28 @@
|
||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||
let removeFontSizeListener: (() => void) | null = null;
|
||||
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let startupWindowRevealed = false;
|
||||
|
||||
const STARTUP_REVEAL_FAILSAFE_MS = 4000;
|
||||
|
||||
async function revealStartupWindow() {
|
||||
if (isMobile || startupWindowRevealed) return;
|
||||
await tick();
|
||||
// Force style resolution while the native window is still hidden so its first
|
||||
// compositor frame already uses the selected theme.
|
||||
getComputedStyle(document.body).backgroundColor;
|
||||
try {
|
||||
await getCurrentWindow().show();
|
||||
startupWindowRevealed = true;
|
||||
if (startupRevealTimer) {
|
||||
clearTimeout(startupRevealTimer);
|
||||
startupRevealTimer = null;
|
||||
}
|
||||
} catch {
|
||||
// Plain-browser development has no Tauri window to reveal.
|
||||
}
|
||||
}
|
||||
|
||||
const defaultEditorFontSize = 14;
|
||||
const minEditorFontSize = 10;
|
||||
@@ -114,6 +137,9 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!isMobile) {
|
||||
startupRevealTimer = setTimeout(() => void revealStartupWindow(), STARTUP_REVEAL_FAILSAFE_MS);
|
||||
}
|
||||
// Check for note window mode
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const notePath = params.get('note');
|
||||
@@ -129,8 +155,13 @@
|
||||
});
|
||||
$theme = config.theme || 'system';
|
||||
|
||||
// Apply theme immediately to prevent flash
|
||||
const themeValue = config.theme || 'system';
|
||||
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
||||
// "system" against the configured pair here the same way resolvedTheme does.
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const rawTheme = config.theme || 'system';
|
||||
const themeValue = rawTheme === 'system'
|
||||
? (prefersDark ? config.system_dark_theme || 'dark' : config.system_light_theme || 'light')
|
||||
: rawTheme;
|
||||
const root = document.documentElement;
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
root.classList.remove('dark');
|
||||
@@ -138,7 +169,7 @@
|
||||
if (namedThemes.includes(themeValue)) {
|
||||
root.setAttribute('data-theme', themeValue);
|
||||
if (darkThemes.includes(themeValue)) root.classList.add('dark');
|
||||
} else if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
} else if (themeValue === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
|
||||
@@ -166,8 +197,8 @@
|
||||
}
|
||||
// Apply saved accent
|
||||
if (config.accent_color) {
|
||||
const themeVal = config.theme || 'light';
|
||||
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const customTheme = config.custom_themes.find(theme => theme.id === themeValue);
|
||||
const isDark = darkThemes.includes(themeValue) || (customTheme?.is_dark ?? false);
|
||||
let color: string | null = null;
|
||||
if (config.accent_color.startsWith('#')) {
|
||||
color = config.accent_color;
|
||||
@@ -209,6 +240,7 @@
|
||||
console.error('Failed to apply interface scale:', e);
|
||||
}
|
||||
}
|
||||
await revealStartupWindow();
|
||||
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||
|
||||
// Auto-open last vault if available
|
||||
@@ -242,14 +274,21 @@
|
||||
}
|
||||
} catch {
|
||||
// First launch or no config
|
||||
} finally {
|
||||
loading = false;
|
||||
await revealStartupWindow();
|
||||
if (startupRevealTimer) {
|
||||
clearTimeout(startupRevealTimer);
|
||||
startupRevealTimer = null;
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
removeFontSizeListener?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { Schema } from '@tiptap/pm/model';
|
||||
import { transformWithEsbuild } from 'vite';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/editor/markdown.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const { code } = await transformWithEsbuild(source, 'markdown.ts', {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'esnext'
|
||||
});
|
||||
const { serializeInlineMarkdown } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||
);
|
||||
|
||||
// Link intentionally precedes formatting marks in the schema. This matches the
|
||||
// mark order produced by the app and reproduces the original per-text-node bug.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
paragraph: { content: 'text*', group: 'block' },
|
||||
text: { group: 'inline' }
|
||||
},
|
||||
marks: {
|
||||
link: { attrs: { href: {} } },
|
||||
bold: {},
|
||||
italic: {},
|
||||
wikiLink: {
|
||||
attrs: {
|
||||
title: { default: null },
|
||||
aliased: { default: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const markdown = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||
const bold = schema.marks.bold.create();
|
||||
const italic = schema.marks.italic.create();
|
||||
const link = schema.marks.link.create({ href: 'https://example.com' });
|
||||
|
||||
function paragraph(segments) {
|
||||
return schema.node(
|
||||
'paragraph',
|
||||
null,
|
||||
segments.map(({ text, marks }) => schema.text(text, marks))
|
||||
);
|
||||
}
|
||||
|
||||
function assertMarkdown(segments, expected, expectedHtml) {
|
||||
const serialized = serializeInlineMarkdown(paragraph(segments));
|
||||
assert.equal(serialized, expected);
|
||||
assert.equal(markdown.render(serialized), `${expectedHtml}\n`);
|
||||
}
|
||||
|
||||
test('keeps bold open when a link ends the marked span', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Bold ', marks: [bold] },
|
||||
{ text: 'link', marks: [link, bold] }
|
||||
],
|
||||
'**Bold [link](https://example.com)**',
|
||||
'<p><strong>Bold <a href="https://example.com">link</a></strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps bold open when a link starts the marked span', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'link', marks: [link, bold] },
|
||||
{ text: ' bold', marks: [bold] }
|
||||
],
|
||||
'**[link](https://example.com) bold**',
|
||||
'<p><strong><a href="https://example.com">link</a> bold</strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps one bold span around a link in the middle', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Before ', marks: [bold] },
|
||||
{ text: 'link', marks: [link, bold] },
|
||||
{ text: ' after', marks: [bold] }
|
||||
],
|
||||
'**Before [link](https://example.com) after**',
|
||||
'<p><strong>Before <a href="https://example.com">link</a> after</strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps nested bold and italic marks open across a link', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Nested ', marks: [bold, italic] },
|
||||
{ text: 'link', marks: [link, bold, italic] },
|
||||
{ text: ' marks', marks: [bold, italic] }
|
||||
],
|
||||
'***Nested [link](https://example.com) marks***',
|
||||
'<p><em><strong>Nested <a href="https://example.com">link</a> marks</strong></em></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves wiki-link aliases inside a bold span', () => {
|
||||
const wikiLink = schema.marks.wikiLink.create({ title: 'Target note', aliased: true });
|
||||
const serialized = serializeInlineMarkdown(
|
||||
paragraph([
|
||||
{ text: 'See ', marks: [bold] },
|
||||
{ text: 'this note', marks: [wikiLink, bold] },
|
||||
{ text: ' now', marks: [bold] }
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(serialized, '**See [[Target note|this note]] now**');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const { compareNaturalNames } = await import(
|
||||
new URL('../src/lib/utils/natural-sort.ts', import.meta.url)
|
||||
);
|
||||
|
||||
test('sorts numeric name segments by numeric value', () => {
|
||||
const names = ['10g', '11g', '12g', '5g', '6g', '7g', '8g', '9g'];
|
||||
|
||||
assert.deepEqual(names.sort(compareNaturalNames), [
|
||||
'5g',
|
||||
'6g',
|
||||
'7g',
|
||||
'8g',
|
||||
'9g',
|
||||
'10g',
|
||||
'11g',
|
||||
'12g'
|
||||
]);
|
||||
});
|
||||
|
||||
test('sorts numbers naturally wherever they appear in a name', () => {
|
||||
const names = ['Class 10b', 'Class 2b', 'Class 10a', 'Class 2a'];
|
||||
|
||||
assert.deepEqual(names.sort(compareNaturalNames), [
|
||||
'Class 2a',
|
||||
'Class 2b',
|
||||
'Class 10a',
|
||||
'Class 10b'
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user