mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
21
Commits
v1.3.4
...
4a1045b41e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
@@ -1231,6 +1231,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)]
|
||||||
@@ -1385,6 +1402,7 @@ pub fn set_general_settings(
|
|||||||
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 +1413,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;
|
||||||
|
|||||||
+26
-9
@@ -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);
|
||||||
@@ -177,6 +170,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,
|
||||||
@@ -321,6 +315,21 @@ pub fn run() {
|
|||||||
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
builder = builder.plugin(tauri_plugin_window_state::Builder::default().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 +419,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>,
|
||||||
@@ -192,6 +203,8 @@ pub struct AppConfig {
|
|||||||
#[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)]
|
||||||
@@ -305,6 +318,7 @@ impl Default for AppConfig {
|
|||||||
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 +483,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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1237,3 +1237,30 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
@@ -216,6 +217,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");
|
||||||
}
|
}
|
||||||
@@ -264,6 +269,7 @@ export async function setGeneralSettings(
|
|||||||
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,
|
||||||
@@ -289,6 +295,7 @@ export async function setGeneralSettings(
|
|||||||
closeToTray,
|
closeToTray,
|
||||||
enableWikiLinks,
|
enableWikiLinks,
|
||||||
showNoteDates,
|
showNoteDates,
|
||||||
|
startupView,
|
||||||
restoreLastSession,
|
restoreLastSession,
|
||||||
showAllNotes,
|
showAllNotes,
|
||||||
showQuickAccess,
|
showQuickAccess,
|
||||||
|
|||||||
@@ -65,8 +65,10 @@
|
|||||||
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
|
||||||
@@ -599,9 +633,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 +673,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 +694,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) {
|
||||||
|
|||||||
+250
-130
@@ -37,7 +37,7 @@
|
|||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { readFile } from '@tauri-apps/plugin-fs';
|
import { readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd, writeBytesTo, copyPngToClipboard } from '$lib/api';
|
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd, writeBytesTo, copyPngToClipboard, copyTextToClipboard } from '$lib/api';
|
||||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks, outlineWidth } from '$lib/stores/app';
|
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks, outlineWidth } from '$lib/stores/app';
|
||||||
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
|
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
||||||
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
||||||
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||||
|
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||||
import { relativePath } from '$lib/utils/paths';
|
import { relativePath } from '$lib/utils/paths';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
@@ -70,12 +71,25 @@
|
|||||||
const LARGE_DOC_CHARS = 100_000;
|
const LARGE_DOC_CHARS = 100_000;
|
||||||
let isLargeDoc = $state(false);
|
let isLargeDoc = $state(false);
|
||||||
let editor: Editor | null = null;
|
let editor: Editor | null = null;
|
||||||
|
const MixedListShortcuts = Extension.create({
|
||||||
|
name: 'mixedListShortcuts',
|
||||||
|
priority: 1000,
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Mod-Shift-8': () => toggleBulletList(),
|
||||||
|
'Mod-Shift-9': () => toggleTaskList(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
let editorReady = $state(false);
|
let editorReady = $state(false);
|
||||||
let sourceContent = $state('');
|
let sourceContent = $state('');
|
||||||
let sourceHistory: Array<{ content: string; cursor: number }> = [];
|
let sourceHistory: Array<{ content: string; cursor: number }> = [];
|
||||||
let sourceHistoryIndex = -1;
|
let sourceHistoryIndex = -1;
|
||||||
let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null;
|
let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let loadedPath = '';
|
let loadedPath = '';
|
||||||
|
type NoteScrollPosition = { rich: number; source: number };
|
||||||
|
const MAX_NOTE_SCROLL_POSITIONS = 200;
|
||||||
|
const noteScrollPositions = new Map<string, NoteScrollPosition>();
|
||||||
let pendingContent = $state<string | null>(null);
|
let pendingContent = $state<string | null>(null);
|
||||||
let ignoreNextUpdate = false;
|
let ignoreNextUpdate = false;
|
||||||
let isLoadingNote = false;
|
let isLoadingNote = false;
|
||||||
@@ -301,11 +315,19 @@
|
|||||||
$outlineWidth = Math.max(160, Math.min(500, $outlineWidth - delta));
|
$outlineWidth = Math.max(160, Math.min(500, $outlineWidth - delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToHeading(pos: number) {
|
async function scrollToHeading(pos: number) {
|
||||||
|
if (isMobile) {
|
||||||
|
showOutline = false;
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
editor.commands.setTextSelection(pos + 1);
|
editor.commands.setTextSelection(pos + 1);
|
||||||
editor.commands.scrollIntoView();
|
editor.commands.scrollIntoView();
|
||||||
editor.view.focus();
|
editor.view.focus();
|
||||||
|
if (isMobile) {
|
||||||
|
const heading = editor.view.nodeDOM(pos);
|
||||||
|
if (heading instanceof HTMLElement) heading.scrollIntoView({ block: 'start' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version history
|
// Version history
|
||||||
@@ -323,6 +345,15 @@
|
|||||||
let infoToggleBtnEl = $state<HTMLElement | null>(null);
|
let infoToggleBtnEl = $state<HTMLElement | null>(null);
|
||||||
let wordCount = $state(0);
|
let wordCount = $state(0);
|
||||||
let charCount = $state(0);
|
let charCount = $state(0);
|
||||||
|
let infoPathCopyState = $state<'idle' | 'copied' | 'error'>('idle');
|
||||||
|
let infoPathCopyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$activeNotePath;
|
||||||
|
infoPathCopyState = 'idle';
|
||||||
|
if (infoPathCopyTimer) clearTimeout(infoPathCopyTimer);
|
||||||
|
infoPathCopyTimer = null;
|
||||||
|
});
|
||||||
|
|
||||||
// In-note search
|
// In-note search
|
||||||
let noteSearchOpen = $state(false);
|
let noteSearchOpen = $state(false);
|
||||||
@@ -364,9 +395,9 @@
|
|||||||
{ label: 'Heading 1', aliases: ['h1', 'heading1', 'title'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16M17 12l3-2v8"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run() },
|
{ label: 'Heading 1', aliases: ['h1', 'heading1', 'title'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16M17 12l3-2v8"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||||
{ label: 'Heading 2', aliases: ['h2', 'heading2', 'subtitle'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run() },
|
{ label: 'Heading 2', aliases: ['h2', 'heading2', 'subtitle'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||||
{ label: 'Heading 3', aliases: ['h3', 'heading3'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 01-2 2m2 0a2 2 0 01-2 2c-1.5 0-3.5 0-3.5-1.5"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run() },
|
{ label: 'Heading 3', aliases: ['h3', 'heading3'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 01-2 2m2 0a2 2 0 01-2 2c-1.5 0-3.5 0-3.5-1.5"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||||
{ label: 'Bullet List', aliases: ['ul', 'unordered', 'bullets', 'list'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3" cy="6" r="1" fill="currentColor"/><circle cx="3" cy="12" r="1" fill="currentColor"/><circle cx="3" cy="18" r="1" fill="currentColor"/></svg>', action: () => editor?.chain().focus().toggleBulletList().run() },
|
{ label: 'Bullet List', aliases: ['ul', 'unordered', 'bullets', 'list'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3" cy="6" r="1" fill="currentColor"/><circle cx="3" cy="12" r="1" fill="currentColor"/><circle cx="3" cy="18" r="1" fill="currentColor"/></svg>', action: toggleBulletList },
|
||||||
{ label: 'Numbered List', aliases: ['ol', 'ordered', 'number'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="9" font-size="8" fill="currentColor" stroke="none">1</text><text x="1" y="15" font-size="8" fill="currentColor" stroke="none">2</text><text x="1" y="21" font-size="8" fill="currentColor" stroke="none">3</text></svg>', action: () => editor?.chain().focus().toggleOrderedList().run() },
|
{ label: 'Numbered List', aliases: ['ol', 'ordered', 'number'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="9" font-size="8" fill="currentColor" stroke="none">1</text><text x="1" y="15" font-size="8" fill="currentColor" stroke="none">2</text><text x="1" y="21" font-size="8" fill="currentColor" stroke="none">3</text></svg>', action: () => editor?.chain().focus().toggleOrderedList().run() },
|
||||||
{ label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: () => editor?.chain().focus().toggleTaskList().run() },
|
{ label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: toggleTaskList },
|
||||||
{ label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() },
|
{ label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() },
|
||||||
{ label: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>', action: () => openSecretInsert() },
|
{ label: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>', action: () => openSecretInsert() },
|
||||||
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
|
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
|
||||||
@@ -3058,19 +3089,27 @@
|
|||||||
|
|
||||||
const scheduleCounts = debounce(updateCounts, 250);
|
const scheduleCounts = debounce(updateCounts, 250);
|
||||||
|
|
||||||
async function toggleInfo() {
|
function toggleInfo() {
|
||||||
if (!showInfo && $activeNote) {
|
|
||||||
historyLoading = true;
|
|
||||||
try {
|
|
||||||
historyVersions = await getNoteVersions($activeNote.meta.id);
|
|
||||||
} catch {
|
|
||||||
historyVersions = [];
|
|
||||||
}
|
|
||||||
historyLoading = false;
|
|
||||||
}
|
|
||||||
showInfo = !showInfo;
|
showInfo = !showInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyActiveNotePath() {
|
||||||
|
const path = $activeNotePath;
|
||||||
|
if (!path) return;
|
||||||
|
if (infoPathCopyTimer) clearTimeout(infoPathCopyTimer);
|
||||||
|
try {
|
||||||
|
await copyTextToClipboard(path);
|
||||||
|
infoPathCopyState = 'copied';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to copy note path:', e);
|
||||||
|
infoPathCopyState = 'error';
|
||||||
|
}
|
||||||
|
infoPathCopyTimer = setTimeout(() => {
|
||||||
|
infoPathCopyState = 'idle';
|
||||||
|
infoPathCopyTimer = null;
|
||||||
|
}, 1600);
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleHistory() {
|
async function toggleHistory() {
|
||||||
showHistory = !showHistory;
|
showHistory = !showHistory;
|
||||||
historyPreview = null;
|
historyPreview = null;
|
||||||
@@ -3200,7 +3239,34 @@
|
|||||||
$editorDirty = false;
|
$editorDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberLoadedNoteScroll() {
|
||||||
|
if (!loadedPath) return;
|
||||||
|
const previous = noteScrollPositions.get(loadedPath) ?? { rich: 0, source: 0 };
|
||||||
|
const editorBody = editorElement?.closest('.editor-body') as HTMLElement | null;
|
||||||
|
const position = $sourceMode
|
||||||
|
? { ...previous, source: sourceElement?.scrollTop ?? previous.source }
|
||||||
|
: { ...previous, rich: editorBody?.scrollTop ?? previous.rich };
|
||||||
|
|
||||||
|
// Refresh insertion order so the cap evicts the least recently viewed note.
|
||||||
|
noteScrollPositions.delete(loadedPath);
|
||||||
|
noteScrollPositions.set(loadedPath, position);
|
||||||
|
if (noteScrollPositions.size > MAX_NOTE_SCROLL_POSITIONS) {
|
||||||
|
const oldestPath = noteScrollPositions.keys().next().value;
|
||||||
|
if (oldestPath) noteScrollPositions.delete(oldestPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreNoteScroll(surface: HTMLElement, scrollTop: number, path: string) {
|
||||||
|
const apply = () => {
|
||||||
|
if (loadedPath === path && surface.isConnected) surface.scrollTop = scrollTop;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
requestAnimationFrame(apply);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadNote(path: string, content: string, taskTarget?: TaskRecord) {
|
export function loadNote(path: string, content: string, taskTarget?: TaskRecord) {
|
||||||
|
rememberLoadedNoteScroll();
|
||||||
|
const scrollPosition = taskTarget ? undefined : noteScrollPositions.get(path);
|
||||||
clearTaskReveal();
|
clearTaskReveal();
|
||||||
const revealRequest = ++taskRevealRequest;
|
const revealRequest = ++taskRevealRequest;
|
||||||
const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null;
|
const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null;
|
||||||
@@ -3220,6 +3286,9 @@
|
|||||||
sourceContent = stripTitleH1(content);
|
sourceContent = stripTitleH1(content);
|
||||||
resetSourceHistory(sourceContent);
|
resetSourceHistory(sourceContent);
|
||||||
if (editorBody) editorBody.scrollTop = 0;
|
if (editorBody) editorBody.scrollTop = 0;
|
||||||
|
tick().then(() => {
|
||||||
|
if (sourceElement) restoreNoteScroll(sourceElement, scrollPosition?.source ?? 0, path);
|
||||||
|
});
|
||||||
isLoadingNote = false;
|
isLoadingNote = false;
|
||||||
updateCounts();
|
updateCounts();
|
||||||
} else if (editorElement && editor) {
|
} else if (editorElement && editor) {
|
||||||
@@ -3232,9 +3301,10 @@
|
|||||||
const text = editor.state.doc.textContent;
|
const text = editor.state.doc.textContent;
|
||||||
wordCount = countWords(text);
|
wordCount = countWords(text);
|
||||||
charCount = text.replace(/\s/g, '').length;
|
charCount = text.replace(/\s/g, '').length;
|
||||||
// Reset scroll and cursor after all ProseMirror/Svelte DOM updates settle
|
// Restore scroll and reset the cursor after all ProseMirror/Svelte DOM updates settle.
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (editorBody) editorBody.scrollTop = 0;
|
if (path !== loadedPath) return;
|
||||||
|
if (editorBody) restoreNoteScroll(editorBody, scrollPosition?.rich ?? 0, path);
|
||||||
// Explicitly reset ProseMirror selection to start so TipTap's focus()
|
// Explicitly reset ProseMirror selection to start so TipTap's focus()
|
||||||
// (triggered by checkbox clicks etc.) doesn't scroll to the old note's cursor position.
|
// (triggered by checkbox clicks etc.) doesn't scroll to the old note's cursor position.
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -3242,7 +3312,6 @@
|
|||||||
// No tr.scrollIntoView() - must not trigger any scroll
|
// No tr.scrollIntoView() - must not trigger any scroll
|
||||||
editor.view.dispatch(tr);
|
editor.view.dispatch(tr);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => { if (editorBody) editorBody.scrollTop = 0; });
|
|
||||||
isLoadingNote = false;
|
isLoadingNote = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -3493,11 +3562,12 @@
|
|||||||
return `$$\n${tex}\n$$\n`;
|
return `$$\n${tex}\n$$\n`;
|
||||||
}
|
}
|
||||||
case 'details': {
|
case 'details': {
|
||||||
// Preserve details as raw HTML
|
// Markdown HTML blocks end at a blank line, including one inside <pre><code>.
|
||||||
|
// Keep the details element on one source line; HTML parsing restores each encoded newline.
|
||||||
const detDiv = document.createElement('div');
|
const detDiv = document.createElement('div');
|
||||||
const detFrag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
|
const detFrag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
|
||||||
detDiv.appendChild(detFrag);
|
detDiv.appendChild(detFrag);
|
||||||
return detDiv.innerHTML + '\n';
|
return detDiv.innerHTML.replace(/\n/g, ' ') + '\n';
|
||||||
}
|
}
|
||||||
case 'image': {
|
case 'image': {
|
||||||
const src = stripAssetSrc(node.attrs.src || '');
|
const src = stripAssetSrc(node.attrs.src || '');
|
||||||
@@ -4309,6 +4379,7 @@
|
|||||||
element: editorElement,
|
element: editorElement,
|
||||||
editable: !$readOnly,
|
editable: !$readOnly,
|
||||||
extensions: [
|
extensions: [
|
||||||
|
MixedListShortcuts,
|
||||||
StarterKit.configure({ codeBlock: false }),
|
StarterKit.configure({ codeBlock: false }),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
@@ -4355,6 +4426,16 @@
|
|||||||
props: {
|
props: {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown(view, event) {
|
keydown(view, event) {
|
||||||
|
const isInsertDetails = (event.ctrlKey || event.metaKey)
|
||||||
|
&& !event.shiftKey
|
||||||
|
&& !event.altKey
|
||||||
|
&& event.code === 'Period';
|
||||||
|
if (isInsertDetails) {
|
||||||
|
if (get(readOnly)) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
insertDetails();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const isTab = event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
|
const isTab = event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||||
const isEnter = event.key === 'Enter' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
|
const isEnter = event.key === 'Enter' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||||
if (!isTab && !isEnter) return false;
|
if (!isTab && !isEnter) return false;
|
||||||
@@ -5116,8 +5197,52 @@
|
|||||||
closeTextContextMenu();
|
closeTextContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMixedListToggle(targetListName: MixedListName): boolean {
|
||||||
|
if (!editor) return false;
|
||||||
|
const { selection, schema } = editor.state;
|
||||||
|
const { $from: fromResolved, $to: toResolved } = selection;
|
||||||
|
let listDepth = -1;
|
||||||
|
|
||||||
|
for (let depth = fromResolved.depth; depth > 0; depth--) {
|
||||||
|
const nodeName = fromResolved.node(depth).type.name;
|
||||||
|
// An ordered list cannot contain task items. Keep it ordered instead of lifting its item.
|
||||||
|
if (nodeName === 'orderedList') return targetListName === 'taskList';
|
||||||
|
if (nodeName === 'bulletList' || nodeName === 'taskList') {
|
||||||
|
listDepth = depth;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listDepth < 0 || toResolved.depth < listDepth) return false;
|
||||||
|
const listNode = fromResolved.node(listDepth);
|
||||||
|
if (listNode.type.name === targetListName || toResolved.node(listDepth) !== listNode) return false;
|
||||||
|
|
||||||
|
const convertedList = convertListNode(schema, listNode, targetListName);
|
||||||
|
if (!convertedList) return false;
|
||||||
|
const listPos = fromResolved.before(listDepth);
|
||||||
|
const transaction = editor.state.tr.replaceWith(
|
||||||
|
listPos,
|
||||||
|
listPos + listNode.nodeSize,
|
||||||
|
convertedList
|
||||||
|
);
|
||||||
|
transaction.setSelection(TextSelection.create(transaction.doc, selection.from, selection.to));
|
||||||
|
editor.view.dispatch(transaction.scrollIntoView());
|
||||||
|
editor.view.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBulletList(): boolean {
|
||||||
|
if (!editor) return false;
|
||||||
|
return handleMixedListToggle('bulletList') || editor.chain().focus().toggleBulletList().run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTaskList(): boolean {
|
||||||
|
if (!editor) return false;
|
||||||
|
return handleMixedListToggle('taskList') || editor.chain().focus().toggleTaskList().run();
|
||||||
|
}
|
||||||
|
|
||||||
function ctxBulletList() {
|
function ctxBulletList() {
|
||||||
editor?.chain().focus().toggleBulletList().run();
|
toggleBulletList();
|
||||||
closeTextContextMenu();
|
closeTextContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5127,7 +5252,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ctxTaskList() {
|
function ctxTaskList() {
|
||||||
editor?.chain().focus().toggleTaskList().run();
|
toggleTaskList();
|
||||||
closeTextContextMenu();
|
closeTextContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5585,6 +5710,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleImageInput(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (file) insertImage(file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
async function insertPdf(file: File) {
|
async function insertPdf(file: File) {
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
@@ -5798,6 +5930,7 @@
|
|||||||
clearTaskReveal();
|
clearTaskReveal();
|
||||||
destroyEditor();
|
destroyEditor();
|
||||||
unlistenFileChange?.();
|
unlistenFileChange?.();
|
||||||
|
if (infoPathCopyTimer) clearTimeout(infoPathCopyTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -6393,37 +6526,35 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{#if !isMobile && $activeNotePath}
|
||||||
|
<div class="info-note-path">
|
||||||
<div class="info-section info-section-versions">
|
<div class="info-note-path-header">
|
||||||
<div class="info-section-header">
|
<span class="info-key">Path on disk</span>
|
||||||
<div class="info-section-label">Snapshots</div>
|
|
||||||
<button class="info-snapshot-btn" onclick={handleCreateVersion} title="Save snapshot">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
Save now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if historyLoading}
|
|
||||||
<div class="info-empty">Loading...</div>
|
|
||||||
{:else if historyVersions.length === 0}
|
|
||||||
<div class="info-empty">No snapshots yet. They're created automatically as you edit.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="info-versions-list">
|
|
||||||
{#each historyVersions as v}
|
|
||||||
<button
|
<button
|
||||||
class="info-version-item"
|
type="button"
|
||||||
class:active={historySelected?.timestamp === v.timestamp}
|
class="info-copy-path-btn"
|
||||||
onclick={() => { previewVersion(v); showHistory = true; showInfo = false; }}
|
class:copied={infoPathCopyState === 'copied'}
|
||||||
|
class:error={infoPathCopyState === 'error'}
|
||||||
|
onclick={copyActiveNotePath}
|
||||||
|
aria-live="polite"
|
||||||
|
title="Copy note path"
|
||||||
>
|
>
|
||||||
<span class="info-version-date">{formatVersionDate(v.timestamp)}</span>
|
{#if infoPathCopyState === 'copied'}
|
||||||
<span class="info-version-size">{formatVersionSize(v.size)}</span>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 4 4L19 6" /></svg>
|
||||||
|
Copied
|
||||||
|
{:else if infoPathCopyState === 'error'}
|
||||||
|
Copy failed
|
||||||
|
{:else}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3" /></svg>
|
||||||
|
Copy
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
</div>
|
||||||
|
<code class="info-note-path-value" title={$activeNotePath}>{$activeNotePath}</code>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -6447,6 +6578,12 @@
|
|||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 00-2.828 0L6 21"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 00-2.828 0L6 21"/></svg>
|
||||||
Image
|
Image
|
||||||
</button>
|
</button>
|
||||||
|
{#if isAndroid}
|
||||||
|
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-camera-input')?.click(); }}>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 00-2 2v9a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>
|
||||||
|
Take Photo
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-file-input')?.click(); }}>
|
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-file-input')?.click(); }}>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg>
|
||||||
File
|
File
|
||||||
@@ -6527,13 +6664,13 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Lists -->
|
<!-- Lists -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title="Bullet List">
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title="Bullet List">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title="Task List">
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title="Task List">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6717,13 +6854,13 @@
|
|||||||
<div class="fmt-sep"></div>
|
<div class="fmt-sep"></div>
|
||||||
|
|
||||||
<!-- Lists -->
|
<!-- Lists -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title={`Bullet List (${modKey}+Shift+8)`}>
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title={`Task List (${modKey}+Shift+9)`}>
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6753,7 +6890,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Collapsible Section -->
|
<!-- Collapsible Section -->
|
||||||
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title="Collapsible Section">
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6925,11 +7062,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Hidden file inputs for Insert dropdown -->
|
<!-- Hidden file inputs for Insert dropdown -->
|
||||||
<input type="file" id="insert-image-input" accept="image/*" style="display:none" onchange={(e) => {
|
<input type="file" id="insert-image-input" accept="image/*" style="display:none" onchange={handleImageInput} />
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
{#if isAndroid}
|
||||||
if (file) insertImage(file);
|
<input type="file" id="insert-camera-input" accept="image/*" capture="environment" style="display:none" onchange={handleImageInput} />
|
||||||
(e.target as HTMLInputElement).value = '';
|
{/if}
|
||||||
}} />
|
|
||||||
<input type="file" id="insert-file-input" style="display:none" onchange={(e) => {
|
<input type="file" id="insert-file-input" style="display:none" onchange={(e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -11394,13 +11530,6 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section-label {
|
.info-section-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -11410,28 +11539,6 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section-header .info-section-label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-snapshot-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 3px 7px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-snapshot-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -11464,6 +11571,64 @@
|
|||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-note-path {
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-note-path-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-copy-path-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: -3px -4px -3px 0;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-copy-path-btn:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-copy-path-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-copy-path-btn.copied {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-copy-path-btn.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-note-path-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.info-tags {
|
.info-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -11479,51 +11644,6 @@
|
|||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-versions-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-version-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-version-item:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-version-item.active {
|
|
||||||
background: var(--bg-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-version-date {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-version-size {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-container.mobile .editor-body-row:has(.info-panel) > .editor-body {
|
.editor-container.mobile .editor-body-row:has(.info-panel) > .editor-body {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
} 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';
|
||||||
@@ -265,6 +266,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;
|
||||||
@@ -1259,11 +1273,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) => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -700,6 +701,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');
|
||||||
@@ -773,6 +775,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;
|
||||||
@@ -795,7 +798,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, 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 +978,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';
|
||||||
@@ -1151,6 +1155,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,15 +1309,6 @@
|
|||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label class="setting-toggle">
|
|
||||||
<span class="setting-label">
|
|
||||||
<span class="setting-name">Restore last session on launch</span>
|
|
||||||
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</span>
|
|
||||||
</span>
|
|
||||||
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
|
|
||||||
<span class="toggle-knob"></span>
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
|
|||||||
@@ -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,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;
|
||||||
@@ -130,6 +132,7 @@ export interface AppConfig {
|
|||||||
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user