mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
31
Commits
v1.3.4
...
bb9d01f57c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb9d01f57c | ||
|
|
ac25dfd773 | ||
|
|
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
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# Local tests
|
||||||
|
/tests/
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
/result
|
/result
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# HelixNotes
|
# HelixNotes
|
||||||
|
|
||||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
[](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)
|
[](https://helixnotes.com)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
|||||||
Your notes are stored as standard Markdown files on your local filesystem.
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
No cloud, no lock-in.
|
No cloud, no lock-in.
|
||||||
|
|
||||||
## Download (v1.3.3)
|
## Download (v1.3.4)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -92,21 +92,23 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||||
|
|
||||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.3/HelixNotes_1.3.3_amd64.AppImage)
|
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||||
|
|
||||||
#### .deb (manual)
|
#### .deb (manual)
|
||||||
|
|
||||||
[Download .deb](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_amd64.deb) (Ubuntu 22.04+)
|
[Download .deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||||
|
|
||||||
|
#### .rpm (manual)
|
||||||
|
|
||||||
|
[Download .rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||||
|
|
||||||
### Windows
|
### 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
|
### 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 (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_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)
|
|
||||||
|
|
||||||
> **"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):
|
> **"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 +118,7 @@ sudo eopkg it helixnotes
|
|||||||
|
|
||||||
### Android
|
### 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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782959384,
|
"lastModified": 1785571196,
|
||||||
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -26,13 +26,13 @@
|
|||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
inherit pname version;
|
inherit pname version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 4;
|
||||||
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-show",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-set-decorations",
|
"core:window:allow-set-decorations",
|
||||||
"core:window:allow-is-fullscreen",
|
"core:window:allow-is-fullscreen",
|
||||||
|
|||||||
@@ -596,6 +596,20 @@ pub fn save_note(
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
pub fn create_note(
|
pub fn create_note(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -1231,6 +1245,23 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
|
|||||||
|
|
||||||
// ── Clipboard ──
|
// ── 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).
|
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
|
||||||
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
|
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
@@ -1381,10 +1412,12 @@ pub fn set_general_settings(
|
|||||||
show_line_numbers: bool,
|
show_line_numbers: bool,
|
||||||
show_link_arrows: bool,
|
show_link_arrows: bool,
|
||||||
default_view_mode: bool,
|
default_view_mode: bool,
|
||||||
|
new_notes_in_source_mode: bool,
|
||||||
show_tray_icon: bool,
|
show_tray_icon: bool,
|
||||||
close_to_tray: bool,
|
close_to_tray: bool,
|
||||||
enable_wiki_links: bool,
|
enable_wiki_links: bool,
|
||||||
show_note_dates: bool,
|
show_note_dates: bool,
|
||||||
|
startup_view: StartupView,
|
||||||
restore_last_session: bool,
|
restore_last_session: bool,
|
||||||
show_all_notes: bool,
|
show_all_notes: bool,
|
||||||
show_quick_access: bool,
|
show_quick_access: bool,
|
||||||
@@ -1395,6 +1428,7 @@ pub fn set_general_settings(
|
|||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.compact_notes = compact_notes;
|
config.compact_notes = compact_notes;
|
||||||
config.show_note_dates = show_note_dates;
|
config.show_note_dates = show_note_dates;
|
||||||
|
config.startup_view = startup_view;
|
||||||
config.restore_last_session = restore_last_session;
|
config.restore_last_session = restore_last_session;
|
||||||
config.show_all_notes = show_all_notes;
|
config.show_all_notes = show_all_notes;
|
||||||
config.show_quick_access = show_quick_access;
|
config.show_quick_access = show_quick_access;
|
||||||
@@ -1413,6 +1447,7 @@ pub fn set_general_settings(
|
|||||||
config.show_line_numbers = show_line_numbers;
|
config.show_line_numbers = show_line_numbers;
|
||||||
config.show_link_arrows = show_link_arrows;
|
config.show_link_arrows = show_link_arrows;
|
||||||
config.default_view_mode = default_view_mode;
|
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.show_tray_icon = show_tray_icon;
|
||||||
config.close_to_tray = close_to_tray;
|
config.close_to_tray = close_to_tray;
|
||||||
config.enable_wiki_links = enable_wiki_links;
|
config.enable_wiki_links = enable_wiki_links;
|
||||||
|
|||||||
+37
-10
@@ -59,16 +59,9 @@ pub fn run() {
|
|||||||
.on_page_load(|webview, payload| {
|
.on_page_load(|webview, payload| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if webview.label() == "main"
|
if webview.label() == "main"
|
||||||
&& matches!(
|
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
|
||||||
payload.event(),
|
|
||||||
tauri::webview::PageLoadEvent::Finished
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
let window = webview.window();
|
queue_macos_traffic_light_fix(&webview.window());
|
||||||
let window_on_main = window.clone();
|
|
||||||
let _ = window.run_on_main_thread(move || {
|
|
||||||
fix_macos_traffic_lights(&window_on_main);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
let _ = (webview, payload);
|
let _ = (webview, payload);
|
||||||
@@ -157,6 +150,7 @@ pub fn run() {
|
|||||||
commands::read_note,
|
commands::read_note,
|
||||||
commands::save_note,
|
commands::save_note,
|
||||||
commands::create_note,
|
commands::create_note,
|
||||||
|
commands::duplicate_note,
|
||||||
commands::create_daily_note,
|
commands::create_daily_note,
|
||||||
commands::rename_note,
|
commands::rename_note,
|
||||||
commands::delete_note,
|
commands::delete_note,
|
||||||
@@ -177,6 +171,7 @@ pub fn run() {
|
|||||||
commands::empty_trash,
|
commands::empty_trash,
|
||||||
commands::load_vault_state,
|
commands::load_vault_state,
|
||||||
commands::save_vault_state,
|
commands::save_vault_state,
|
||||||
|
commands::copy_text_to_clipboard,
|
||||||
commands::read_clipboard_image,
|
commands::read_clipboard_image,
|
||||||
commands::copy_image_to_clipboard,
|
commands::copy_image_to_clipboard,
|
||||||
commands::save_image,
|
commands::save_image,
|
||||||
@@ -318,9 +313,33 @@ pub fn run() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
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| {
|
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 {
|
match event {
|
||||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
// Only hide to tray for the main window
|
// Only hide to tray for the main window
|
||||||
@@ -410,6 +429,14 @@ fn percent_decode(input: &str) -> String {
|
|||||||
String::from_utf8_lossy(&output).to_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")]
|
#[cfg(target_os = "macos")]
|
||||||
fn fix_macos_traffic_lights(window: &tauri::Window) {
|
fn fix_macos_traffic_lights(window: &tauri::Window) {
|
||||||
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use crate::search::SearchIndex;
|
use crate::search::SearchIndex;
|
||||||
use crate::types::AppConfig;
|
use crate::types::AppConfig;
|
||||||
use notify::RecommendedWatcher;
|
use crate::vault::watcher::VaultWatcher;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<AppConfig>,
|
pub config: Mutex<AppConfig>,
|
||||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
pub watcher: Mutex<Option<VaultWatcher>>,
|
||||||
pub vault_transition: tokio::sync::Mutex<()>,
|
pub vault_transition: tokio::sync::Mutex<()>,
|
||||||
pub importing: AtomicBool,
|
pub importing: AtomicBool,
|
||||||
pub syncing: AtomicBool,
|
pub syncing: AtomicBool,
|
||||||
|
|||||||
+32
-7
@@ -6,9 +6,9 @@
|
|||||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||||
// conflict copies so nothing is ever lost.
|
// conflict copies so nothing is ever lost.
|
||||||
//
|
//
|
||||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
||||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
||||||
// manifest itself) is local-only and never synced.
|
// and the manifest itself remain local-only.
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::vault::operations::helixnotes_dir;
|
use crate::vault::operations::helixnotes_dir;
|
||||||
@@ -119,12 +119,12 @@ struct LocalFile {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
||||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
|
||||||
/// folder with unrelated files never drags them into the vault.
|
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||||
fn is_synced_relpath(rel: &str) -> bool {
|
fn is_synced_relpath(rel: &str) -> bool {
|
||||||
if rel.starts_with(".helixnotes/") {
|
if rel.starts_with(".helixnotes/") {
|
||||||
rel.starts_with(".helixnotes/attachments/")
|
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
|
||||||
} else {
|
} else {
|
||||||
rel.ends_with(".md")
|
rel.ends_with(".md")
|
||||||
}
|
}
|
||||||
@@ -724,3 +724,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
|||||||
);
|
);
|
||||||
Ok(summary)
|
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,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub vaults: Vec<VaultConfig>,
|
pub vaults: Vec<VaultConfig>,
|
||||||
@@ -186,12 +197,16 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_view_mode: bool,
|
pub default_view_mode: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub new_notes_in_source_mode: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub show_tray_icon: bool,
|
pub show_tray_icon: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub close_to_tray: bool,
|
pub close_to_tray: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enable_wiki_links: bool,
|
pub enable_wiki_links: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub startup_view: StartupView,
|
||||||
|
#[serde(default)]
|
||||||
pub restore_last_session: bool,
|
pub restore_last_session: bool,
|
||||||
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -302,9 +317,11 @@ impl Default for AppConfig {
|
|||||||
ai_model: "claude-sonnet-4-6".to_string(),
|
ai_model: "claude-sonnet-4-6".to_string(),
|
||||||
ai_writing_style: None,
|
ai_writing_style: None,
|
||||||
default_view_mode: false,
|
default_view_mode: false,
|
||||||
|
new_notes_in_source_mode: false,
|
||||||
show_tray_icon: false,
|
show_tray_icon: false,
|
||||||
close_to_tray: false,
|
close_to_tray: false,
|
||||||
enable_wiki_links: true,
|
enable_wiki_links: true,
|
||||||
|
startup_view: StartupView::All,
|
||||||
restore_last_session: false,
|
restore_last_session: false,
|
||||||
sync_provider: None,
|
sync_provider: None,
|
||||||
webdav_url: None,
|
webdav_url: None,
|
||||||
@@ -469,3 +486,28 @@ pub struct TaskItem {
|
|||||||
pub due: Option<String>,
|
pub due: Option<String>,
|
||||||
pub priority: Option<String>,
|
pub priority: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod startup_view_tests {
|
||||||
|
use super::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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -476,6 +476,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 {
|
fn get_system_locale() -> Locale {
|
||||||
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
|
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
|
||||||
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
|
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
|
||||||
@@ -1237,3 +1306,62 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{duplicate_note, helixnotes_dir, load_notebook_icons, set_notebook_icon};
|
||||||
|
use std::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[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, "Projects", Some("builtin:briefcase")).unwrap();
|
||||||
|
let icons = load_notebook_icons(&vault_path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
icons.get("Projects").map(String::as_str),
|
||||||
|
Some("builtin:briefcase")
|
||||||
|
);
|
||||||
|
|
||||||
|
set_notebook_icon(&vault_path, "Projects", None).unwrap();
|
||||||
|
assert!(load_notebook_icons(&vault_path).unwrap().is_empty());
|
||||||
|
|
||||||
|
fs::remove_dir_all(vault).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::path::Path;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
|||||||
use crate::types::FileEvent;
|
use crate::types::FileEvent;
|
||||||
use crate::vault::operations::helixnotes_dir;
|
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 (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
let mut watcher = RecommendedWatcher::new(
|
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||||
tx,
|
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
let mut watcher = match backend {
|
||||||
)
|
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||||
.map_err(|e| e.to_string())?;
|
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
|
watcher
|
||||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||||
@@ -80,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
|||||||
|
|
||||||
Ok(watcher)
|
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,
|
VersionEntry,
|
||||||
TaskItem,
|
TaskItem,
|
||||||
ExternalVaultResult,
|
ExternalVaultResult,
|
||||||
|
StartupView,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export async function openVault(path: string): Promise<void> {
|
export async function openVault(path: string): Promise<void> {
|
||||||
@@ -140,6 +141,10 @@ export async function createNote(
|
|||||||
return invoke("create_note", { notebookRelative, title });
|
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> {
|
export async function createDailyNote(date?: string): Promise<NoteEntry> {
|
||||||
return invoke("create_daily_note", { date: date ?? null });
|
return invoke("create_daily_note", { date: date ?? null });
|
||||||
}
|
}
|
||||||
@@ -216,6 +221,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
|
|||||||
return invoke("save_vault_state", { vaultState });
|
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[]> {
|
export async function readClipboardImage(): Promise<number[]> {
|
||||||
return invoke("read_clipboard_image");
|
return invoke("read_clipboard_image");
|
||||||
}
|
}
|
||||||
@@ -260,10 +269,12 @@ export async function setGeneralSettings(
|
|||||||
showLineNumbers: boolean,
|
showLineNumbers: boolean,
|
||||||
showLinkArrows: boolean,
|
showLinkArrows: boolean,
|
||||||
defaultViewMode: boolean,
|
defaultViewMode: boolean,
|
||||||
|
newNotesInSourceMode: boolean,
|
||||||
showTrayIcon: boolean,
|
showTrayIcon: boolean,
|
||||||
closeToTray: boolean,
|
closeToTray: boolean,
|
||||||
enableWikiLinks: boolean,
|
enableWikiLinks: boolean,
|
||||||
showNoteDates: boolean,
|
showNoteDates: boolean,
|
||||||
|
startupView: StartupView,
|
||||||
restoreLastSession: boolean,
|
restoreLastSession: boolean,
|
||||||
showAllNotes: boolean,
|
showAllNotes: boolean,
|
||||||
showQuickAccess: boolean,
|
showQuickAccess: boolean,
|
||||||
@@ -285,10 +296,12 @@ export async function setGeneralSettings(
|
|||||||
showLineNumbers,
|
showLineNumbers,
|
||||||
showLinkArrows,
|
showLinkArrows,
|
||||||
defaultViewMode,
|
defaultViewMode,
|
||||||
|
newNotesInSourceMode,
|
||||||
showTrayIcon,
|
showTrayIcon,
|
||||||
closeToTray,
|
closeToTray,
|
||||||
enableWikiLinks,
|
enableWikiLinks,
|
||||||
showNoteDates,
|
showNoteDates,
|
||||||
|
startupView,
|
||||||
restoreLastSession,
|
restoreLastSession,
|
||||||
showAllNotes,
|
showAllNotes,
|
||||||
showQuickAccess,
|
showQuickAccess,
|
||||||
|
|||||||
@@ -61,12 +61,14 @@
|
|||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
const isMobile = $derived($platformIsMobile);
|
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 { darkThemes, isAndroid } from '$lib/platform';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
|
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
|
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 {
|
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
|
||||||
for (const nb of list) {
|
for (const nb of list) {
|
||||||
@@ -81,6 +83,38 @@
|
|||||||
let noteList: NoteList;
|
let noteList: NoteList;
|
||||||
let editor: Editor;
|
let editor: Editor;
|
||||||
let unlistenFileChange: (() => void) | null = null;
|
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;
|
let unlistenOpenFile: (() => void) | null = null;
|
||||||
|
|
||||||
// Mobile editor header helpers
|
// Mobile editor header helpers
|
||||||
@@ -415,6 +449,28 @@
|
|||||||
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
|
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) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
|
||||||
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
|
||||||
@@ -433,7 +489,7 @@
|
|||||||
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||||
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
noteList?.trashActiveNote();
|
editor?.moveOpenNoteToTrash();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,9 +655,11 @@
|
|||||||
lastTag = state.last_tag ?? null;
|
lastTag = state.last_tag ?? null;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
const restoreLastSession = $appConfig?.restore_last_session === true;
|
||||||
|
|
||||||
// On mobile, prefetch last-opened note so first tap is instant
|
// On mobile, prefetch last-opened note so first tap is instant
|
||||||
let prefetchPromise: Promise<any> | null = null;
|
let prefetchPromise: Promise<any> | null = null;
|
||||||
if (isMobile && lastNotePath) {
|
if (isMobile && restoreLastSession && lastNotePath) {
|
||||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,24 +695,19 @@
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the last session (view + open note) if enabled.
|
const startupTarget = resolveStartupTarget({
|
||||||
if ($appConfig?.restore_last_session) {
|
startupView: $appConfig?.startup_view,
|
||||||
const vault = $appConfig?.active_vault;
|
restoreLastSession,
|
||||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
lastViewMode,
|
||||||
$viewMode = 'notebook';
|
lastNotebook,
|
||||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
lastTag
|
||||||
$activeTag = null;
|
});
|
||||||
await noteList?.refresh();
|
if (!(await applyStartupTarget(startupTarget))) {
|
||||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
|
||||||
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) {
|
// Reopen the last note only when session restoration is enabled.
|
||||||
|
if (restoreLastSession && !isMobile && lastNotePath) {
|
||||||
try {
|
try {
|
||||||
const content = await readNote(lastNotePath);
|
const content = await readNote(lastNotePath);
|
||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
@@ -663,7 +716,6 @@
|
|||||||
editor?.loadNote(lastNotePath, content.content);
|
editor?.loadNote(lastNotePath, content.content);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -918,10 +970,10 @@
|
|||||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
<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>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||||
<Editor bind:this={editor} />
|
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -979,7 +1031,7 @@
|
|||||||
|
|
||||||
{#if !$notelistCollapsed}
|
{#if !$notelistCollapsed}
|
||||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
<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>
|
</div>
|
||||||
|
|
||||||
<ResizeHandle onResize={handleNotelistResize} />
|
<ResizeHandle onResize={handleNotelistResize} />
|
||||||
@@ -998,7 +1050,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="editor-panel">
|
<div class="editor-panel">
|
||||||
<Editor bind:this={editor} />
|
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||||
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
||||||
<div class="tasks-editor-placeholder">
|
<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">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
|||||||
+505
-233
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,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showInfo, appConfig } from '$lib/stores/app';
|
import { showInfo } from '$lib/stores/app';
|
||||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
import type { OrphanAttachment } from '$lib/api';
|
import type { OrphanAttachment } from '$lib/api';
|
||||||
import { openUrl } from '$lib/api';
|
import { openUrl } from '$lib/api';
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
|
||||||
<div class="info-header">
|
<div class="info-header">
|
||||||
<h2>Info</h2>
|
<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">
|
<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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
@@ -273,6 +273,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">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">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">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">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">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>
|
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
getNotes,
|
getNotes,
|
||||||
readNote,
|
readNote,
|
||||||
createNote,
|
createNote,
|
||||||
|
duplicateNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
renameNote,
|
renameNote,
|
||||||
saveNote,
|
saveNote,
|
||||||
@@ -40,16 +41,18 @@
|
|||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
|
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||||
import TasksView from './TasksView.svelte';
|
import TasksView from './TasksView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
import { isMobile, isAndroid } from '$lib/platform';
|
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;
|
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||||
onNoteMoved?: () => void;
|
onNoteMoved?: () => void;
|
||||||
onBeforeNoteSwitch?: () => void;
|
onBeforeNoteSwitch?: () => void;
|
||||||
|
onBeforeNoteDuplicate?: () => Promise<boolean>;
|
||||||
onNoteCreated?: () => void;
|
onNoteCreated?: () => void;
|
||||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||||
@@ -265,6 +268,19 @@
|
|||||||
if (listContainer) listContainer.scrollTop = 0;
|
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)
|
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$quickAccessPaths;
|
$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) {
|
function handleListDoubleClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
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) {
|
async function handleRestore(note: NoteEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
@@ -1259,11 +1290,10 @@
|
|||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
|
||||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
? selectedPaths
|
||||||
} else {
|
: [note.path];
|
||||||
e.dataTransfer!.setData('text/plain', note.path);
|
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
|
||||||
}
|
|
||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
}}
|
}}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
@@ -1378,7 +1408,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#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)}
|
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||||
<!-- Batch context menu -->
|
<!-- Batch context menu -->
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
@@ -1542,6 +1572,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</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; }}>
|
<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">
|
<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"/>
|
<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;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .context-menu {
|
.context-menu.mobile {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
max-height: calc(100dvh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .context-menu button {
|
.context-menu.mobile button {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { openUrl } from '$lib/api';
|
import { openUrl } from '$lib/api';
|
||||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
|
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView } from '$lib/types';
|
||||||
|
import { normalizeStartupView } from '$lib/utils/startup-view';
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
|
|
||||||
@@ -584,12 +585,14 @@
|
|||||||
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
|
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
|
||||||
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
|
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
|
||||||
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
|
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);
|
root.classList.toggle('dark', customThemeEditing.is_dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreCurrentTheme() {
|
function restoreCurrentTheme() {
|
||||||
const root = document.documentElement;
|
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.classList.remove('dark');
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
for (const v of varsToClear) root.style.removeProperty(v);
|
for (const v of varsToClear) root.style.removeProperty(v);
|
||||||
@@ -606,6 +609,8 @@
|
|||||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
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');
|
if (ct.is_dark) root.classList.add('dark');
|
||||||
}
|
}
|
||||||
} else if (namedThemes.includes($theme)) {
|
} else if (namedThemes.includes($theme)) {
|
||||||
@@ -700,6 +705,7 @@
|
|||||||
// General settings
|
// General settings
|
||||||
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
||||||
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
||||||
|
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
|
||||||
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
|
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
|
||||||
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
||||||
let weekStart = $state($appConfig?.week_start ?? 'monday');
|
let weekStart = $state($appConfig?.week_start ?? 'monday');
|
||||||
@@ -715,6 +721,7 @@
|
|||||||
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
|
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
|
||||||
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
|
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
|
||||||
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
|
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 showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
|
||||||
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
||||||
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
|
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
|
||||||
@@ -773,6 +780,7 @@
|
|||||||
if ($appConfig) {
|
if ($appConfig) {
|
||||||
$appConfig.compact_notes = compactNotes;
|
$appConfig.compact_notes = compactNotes;
|
||||||
$appConfig.show_note_dates = showNoteDates;
|
$appConfig.show_note_dates = showNoteDates;
|
||||||
|
$appConfig.startup_view = startupView;
|
||||||
$appConfig.restore_last_session = restoreLastSession;
|
$appConfig.restore_last_session = restoreLastSession;
|
||||||
$appConfig.time_format = timeFormat;
|
$appConfig.time_format = timeFormat;
|
||||||
$appConfig.week_start = weekStart;
|
$appConfig.week_start = weekStart;
|
||||||
@@ -786,6 +794,7 @@
|
|||||||
$appConfig.show_line_numbers = showLineNumbers;
|
$appConfig.show_line_numbers = showLineNumbers;
|
||||||
$appConfig.show_link_arrows = showLinkArrows;
|
$appConfig.show_link_arrows = showLinkArrows;
|
||||||
$appConfig.default_view_mode = defaultViewMode;
|
$appConfig.default_view_mode = defaultViewMode;
|
||||||
|
$appConfig.new_notes_in_source_mode = newNotesInSourceMode;
|
||||||
$appConfig.show_tray_icon = showTrayIcon;
|
$appConfig.show_tray_icon = showTrayIcon;
|
||||||
$appConfig.close_to_tray = closeToTray;
|
$appConfig.close_to_tray = closeToTray;
|
||||||
$appConfig.enable_wiki_links = enableWikiLinks;
|
$appConfig.enable_wiki_links = enableWikiLinks;
|
||||||
@@ -795,7 +804,7 @@
|
|||||||
$appConfig.show_daily_notes = showDailyNotes;
|
$appConfig.show_daily_notes = showDailyNotes;
|
||||||
$appConfig.show_trash = showTrash;
|
$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, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||||
.catch((e) => console.error('Failed to save general settings:', e));
|
.catch((e) => console.error('Failed to save general settings:', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,6 +984,7 @@
|
|||||||
if ($appConfig) {
|
if ($appConfig) {
|
||||||
compactNotes = $appConfig.compact_notes ?? false;
|
compactNotes = $appConfig.compact_notes ?? false;
|
||||||
showNoteDates = $appConfig.show_note_dates ?? true;
|
showNoteDates = $appConfig.show_note_dates ?? true;
|
||||||
|
startupView = normalizeStartupView($appConfig.startup_view);
|
||||||
restoreLastSession = $appConfig.restore_last_session ?? false;
|
restoreLastSession = $appConfig.restore_last_session ?? false;
|
||||||
timeFormat = $appConfig.time_format ?? 'relative';
|
timeFormat = $appConfig.time_format ?? 'relative';
|
||||||
weekStart = $appConfig.week_start ?? 'monday';
|
weekStart = $appConfig.week_start ?? 'monday';
|
||||||
@@ -984,6 +994,7 @@
|
|||||||
pdfPreview = $appConfig.pdf_preview ?? false;
|
pdfPreview = $appConfig.pdf_preview ?? false;
|
||||||
pdfHeight = $appConfig.pdf_height ?? 600;
|
pdfHeight = $appConfig.pdf_height ?? 600;
|
||||||
titleMode = $appConfig.title_mode ?? 'input';
|
titleMode = $appConfig.title_mode ?? 'input';
|
||||||
|
newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false;
|
||||||
showAllNotes = $appConfig.show_all_notes ?? true;
|
showAllNotes = $appConfig.show_all_notes ?? true;
|
||||||
showQuickAccess = $appConfig.show_quick_access ?? true;
|
showQuickAccess = $appConfig.show_quick_access ?? true;
|
||||||
showTasks = $appConfig.show_tasks ?? true;
|
showTasks = $appConfig.show_tasks ?? true;
|
||||||
@@ -1151,6 +1162,29 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="settings-section">
|
||||||
<h3>Time Format</h3>
|
<h3>Time Format</h3>
|
||||||
<div class="setting-options">
|
<div class="setting-options">
|
||||||
@@ -1282,17 +1316,18 @@
|
|||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle" style="margin-top: 12px;">
|
||||||
<span class="setting-label">
|
<span class="setting-label">
|
||||||
<span class="setting-name">Restore last session on launch</span>
|
<span class="setting-name">Open new notes in source mode</span>
|
||||||
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</span>
|
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
|
||||||
</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>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Wiki Links & Graph</h3>
|
<h3>Wiki Links & Graph</h3>
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
|
|||||||
@@ -27,6 +27,14 @@
|
|||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import type { NotebookEntry } from '$lib/types';
|
import type { NotebookEntry } from '$lib/types';
|
||||||
import { isMobile } from '$lib/platform';
|
import { isMobile } from '$lib/platform';
|
||||||
|
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||||
|
import NotebookGlyph from './NotebookGlyph.svelte';
|
||||||
|
import {
|
||||||
|
NOTEBOOK_ICON_OPTIONS,
|
||||||
|
decodeBuiltinNotebookIcon,
|
||||||
|
encodeBuiltinNotebookIcon,
|
||||||
|
type NotebookIconId
|
||||||
|
} from '$lib/utils/notebook-icons';
|
||||||
|
|
||||||
let { onViewChanged = () => {} }: {
|
let { onViewChanged = () => {} }: {
|
||||||
onViewChanged?: () => void;
|
onViewChanged?: () => void;
|
||||||
@@ -78,6 +86,8 @@
|
|||||||
}
|
}
|
||||||
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
|
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
|
||||||
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
|
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
|
||||||
|
let iconPickerNotebook = $state<NotebookEntry | null>(null);
|
||||||
|
let iconPickerElement = $state<HTMLDivElement | null>(null);
|
||||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tagsCollapsed = $state(true);
|
let tagsCollapsed = $state(true);
|
||||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||||
@@ -340,22 +350,32 @@
|
|||||||
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
|
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropTargetPath = null;
|
dropTargetPath = null;
|
||||||
const notePath = e.dataTransfer?.getData('text/plain');
|
const payload = e.dataTransfer?.getData('text/plain') ?? '';
|
||||||
if (!notePath) return;
|
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
|
||||||
// Don't move if already in this notebook
|
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
|
||||||
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
if (notePaths.length === 0) return;
|
||||||
if (noteDir === nb.path) return;
|
|
||||||
|
const movedPaths = new Map<string, string>();
|
||||||
|
for (const notePath of notePaths) {
|
||||||
try {
|
try {
|
||||||
const newPath = await moveNote(notePath, nb.path);
|
movedPaths.set(notePath, await moveNote(notePath, nb.path));
|
||||||
$notes = $notes.filter(n => n.path !== notePath);
|
} catch (e) {
|
||||||
if ($activeNotePath === notePath) {
|
console.error('Failed to move note:', notePath, e);
|
||||||
$activeNotePath = newPath;
|
}
|
||||||
$activeNote = await readNote(newPath);
|
}
|
||||||
|
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();
|
await refresh();
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to move note:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||||
@@ -558,21 +578,38 @@
|
|||||||
renameInput?.select();
|
renameInput?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSetIcon(nb: NotebookEntry) {
|
async function openIconPicker(nb: NotebookEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
|
iconPickerNotebook = nb;
|
||||||
|
await tick();
|
||||||
|
iconPickerElement?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
|
||||||
|
try {
|
||||||
|
const value = encodeBuiltinNotebookIcon(icon);
|
||||||
|
await setNotebookIcon(nb.relative_path, value);
|
||||||
|
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: value };
|
||||||
|
iconPickerNotebook = null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set notebook icon:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCustomIcon(nb: NotebookEntry) {
|
||||||
|
iconPickerNotebook = null;
|
||||||
try {
|
try {
|
||||||
const selected = await openDialog({
|
const selected = await openDialog({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
|
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
|
||||||
});
|
});
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const filePath = typeof selected === 'string' ? selected : selected;
|
const filePath = selected as string;
|
||||||
// Read the file and save as attachment
|
const data = await readFile(filePath);
|
||||||
const data = await readFile(filePath as string);
|
const fileName = baseOf(filePath) || 'icon.png';
|
||||||
const fileName = (filePath as string).split('/').pop() || 'icon.png';
|
|
||||||
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
|
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
|
||||||
await setNotebookIcon(nb.relative_path, iconRelative);
|
await setNotebookIcon(nb.relative_path, iconRelative);
|
||||||
$notebookIcons = await getNotebookIcons();
|
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to set notebook icon:', e);
|
console.error('Failed to set notebook icon:', e);
|
||||||
}
|
}
|
||||||
@@ -580,9 +617,12 @@
|
|||||||
|
|
||||||
async function handleRemoveIcon(nb: NotebookEntry) {
|
async function handleRemoveIcon(nb: NotebookEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
|
iconPickerNotebook = null;
|
||||||
try {
|
try {
|
||||||
await setNotebookIcon(nb.relative_path, null);
|
await setNotebookIcon(nb.relative_path, null);
|
||||||
$notebookIcons = await getNotebookIcons();
|
const icons = { ...$notebookIcons };
|
||||||
|
delete icons[nb.relative_path];
|
||||||
|
$notebookIcons = icons;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to remove notebook icon:', e);
|
console.error('Failed to remove notebook icon:', e);
|
||||||
}
|
}
|
||||||
@@ -590,7 +630,7 @@
|
|||||||
|
|
||||||
function getNotebookIconSrc(nb: NotebookEntry): string | null {
|
function getNotebookIconSrc(nb: NotebookEntry): string | null {
|
||||||
const iconPath = $notebookIcons[nb.relative_path];
|
const iconPath = $notebookIcons[nb.relative_path];
|
||||||
if (!iconPath) return null;
|
if (!iconPath || iconPath.startsWith('builtin:')) return null;
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
if (!vaultRoot) return null;
|
if (!vaultRoot) return null;
|
||||||
return convertFileSrc(`${vaultRoot}/${iconPath}`);
|
return convertFileSrc(`${vaultRoot}/${iconPath}`);
|
||||||
@@ -610,6 +650,14 @@
|
|||||||
contextMenu = { x, y, notebook: nb };
|
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() {
|
function closeContextMenu() {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
}
|
}
|
||||||
@@ -642,7 +690,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}>
|
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
@@ -892,8 +940,11 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#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 -->
|
<!-- 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)}>
|
<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>
|
<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
|
New Sub-notebook
|
||||||
@@ -902,16 +953,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>
|
<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
|
Rename
|
||||||
</button>
|
</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>
|
<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[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'}
|
||||||
</button>
|
</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)}>
|
<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>
|
<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
|
Delete
|
||||||
@@ -919,9 +964,56 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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[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[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 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 -->
|
<!-- 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}>
|
<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">
|
<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" />
|
<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 +1027,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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>
|
<h4>Delete "{deleteConfirm.name}"?</h4>
|
||||||
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
|
||||||
<div class="delete-confirm-actions">
|
<div class="delete-confirm-actions">
|
||||||
@@ -950,6 +1042,7 @@
|
|||||||
{@const hasChildren = nb.children.length > 0}
|
{@const hasChildren = nb.children.length > 0}
|
||||||
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
|
||||||
{@const iconSrc = getNotebookIconSrc(nb)}
|
{@const iconSrc = getNotebookIconSrc(nb)}
|
||||||
|
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])}
|
||||||
{#if editingNotebook === nb.path}
|
{#if editingNotebook === nb.path}
|
||||||
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
|
||||||
<input
|
<input
|
||||||
@@ -965,6 +1058,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="notebook-row" data-nb-path={nb.path}>
|
||||||
<button
|
<button
|
||||||
class="notebook-item"
|
class="notebook-item"
|
||||||
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
||||||
@@ -1037,7 +1131,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="chevron-spacer"></span>
|
<span class="chevron-spacer"></span>
|
||||||
{/if}
|
{/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="" />
|
<img class="notebook-icon" src={iconSrc} alt="" />
|
||||||
{:else if isCollapsed || nb.children.length === 0}
|
{: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">
|
<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 +1160,17 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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}
|
||||||
{#if hasChildren && !isCollapsed}
|
{#if hasChildren && !isCollapsed}
|
||||||
{#each nb.children as child (child.path)}
|
{#each nb.children as child (child.path)}
|
||||||
@@ -1306,6 +1413,10 @@
|
|||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notebook-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.notebook-item {
|
.notebook-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1378,6 +1489,15 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notebook-builtin-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.notebook-name {
|
.notebook-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -1413,6 +1533,28 @@
|
|||||||
cursor: grabbing;
|
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 {
|
.tag-list {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
@@ -1481,6 +1623,15 @@
|
|||||||
min-width: 140px;
|
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 {
|
.context-menu button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1508,6 +1659,195 @@
|
|||||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
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 {
|
.delete-confirm-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1635,7 +1975,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .notebook-item {
|
.sidebar.mobile .notebook-item {
|
||||||
padding: 12px 12px;
|
padding: 2px 56px 2px 12px;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -1646,6 +1986,11 @@
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.mobile .notebook-builtin-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.mobile .new-notebook-input {
|
.sidebar.mobile .new-notebook-input {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
}
|
}
|
||||||
@@ -1676,34 +2021,40 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .context-menu {
|
.context-menu.mobile {
|
||||||
min-width: 200px;
|
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;
|
border-radius: 12px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .context-menu button {
|
.context-menu.mobile button {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm {
|
.delete-confirm.mobile {
|
||||||
max-width: calc(100vw - 40px);
|
max-width: calc(100vw - 40px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm h4 {
|
.delete-confirm.mobile h4 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm p {
|
.delete-confirm.mobile p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile .delete-confirm-cancel,
|
.delete-confirm.mobile .delete-confirm-cancel,
|
||||||
.sidebar.mobile .delete-confirm-btn {
|
.delete-confirm.mobile .delete-confirm-btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ export interface CustomTheme {
|
|||||||
colors: CustomThemeColors;
|
colors: CustomThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StartupView = "all" | "quickaccess" | "tasks" | "daily";
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
vaults: VaultConfig[];
|
vaults: VaultConfig[];
|
||||||
active_vault: string | null;
|
active_vault: string | null;
|
||||||
@@ -127,9 +129,11 @@ export interface AppConfig {
|
|||||||
ai_model: string;
|
ai_model: string;
|
||||||
ai_writing_style: string | null;
|
ai_writing_style: string | null;
|
||||||
default_view_mode: boolean;
|
default_view_mode: boolean;
|
||||||
|
new_notes_in_source_mode: boolean;
|
||||||
show_tray_icon: boolean;
|
show_tray_icon: boolean;
|
||||||
close_to_tray: boolean;
|
close_to_tray: boolean;
|
||||||
enable_wiki_links: boolean;
|
enable_wiki_links: boolean;
|
||||||
|
startup_view: StartupView;
|
||||||
restore_last_session: boolean;
|
restore_last_session: boolean;
|
||||||
sync_provider: string | null;
|
sync_provider: string | null;
|
||||||
webdav_url: string | null;
|
webdav_url: string | null;
|
||||||
|
|||||||
@@ -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,50 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -26,8 +26,13 @@
|
|||||||
'--bg-primary', '--bg-secondary', '--bg-tertiary',
|
'--bg-primary', '--bg-secondary', '--bg-tertiary',
|
||||||
'--bg-hover', '--bg-active', '--bg-editor',
|
'--bg-hover', '--bg-active', '--bg-editor',
|
||||||
'--text-primary', '--text-secondary', '--border-color',
|
'--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) {
|
function applyCustomThemeVars(root: HTMLElement, ct: CustomTheme) {
|
||||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||||
@@ -38,6 +43,8 @@
|
|||||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
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) {
|
function clearCustomThemeVars(root: HTMLElement) {
|
||||||
|
|||||||
+37
-3
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
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 { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||||
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
||||||
@@ -15,6 +16,28 @@
|
|||||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||||
let removeFontSizeListener: (() => 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 defaultEditorFontSize = 14;
|
||||||
const minEditorFontSize = 10;
|
const minEditorFontSize = 10;
|
||||||
@@ -114,6 +137,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (!isMobile) {
|
||||||
|
startupRevealTimer = setTimeout(() => void revealStartupWindow(), STARTUP_REVEAL_FAILSAFE_MS);
|
||||||
|
}
|
||||||
// Check for note window mode
|
// Check for note window mode
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const notePath = params.get('note');
|
const notePath = params.get('note');
|
||||||
@@ -209,6 +235,7 @@
|
|||||||
console.error('Failed to apply interface scale:', e);
|
console.error('Failed to apply interface scale:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await revealStartupWindow();
|
||||||
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||||
|
|
||||||
// Auto-open last vault if available
|
// Auto-open last vault if available
|
||||||
@@ -242,14 +269,21 @@
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// First launch or no config
|
// First launch or no config
|
||||||
}
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
await revealStartupWindow();
|
||||||
|
if (startupRevealTimer) {
|
||||||
|
clearTimeout(startupRevealTimer);
|
||||||
|
startupRevealTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
removeEditorZoomShortcuts?.();
|
removeEditorZoomShortcuts?.();
|
||||||
removeFontSizeListener?.();
|
removeFontSizeListener?.();
|
||||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||||
|
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</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**');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user