Compare commits

...
28 Commits
Author SHA1 Message Date
Yuri Karamian 2dd0985ea9 Add Android image pinch zoom (Closes #115) 2026-08-03 16:56:38 +02:00
Yuri Karamian 18bb6bb702 Fix Linux startup theme flash (#195) 2026-08-03 15:54:17 +02:00
Yuri Karamian fcf48a5b7a Fix Markdown mark round-trips (#219) 2026-08-03 14:04:49 +02:00
Yuri Karamian c7906f4069 Keep source mode last in editor toolbar 2026-08-03 13:31:54 +02:00
Yuri Karamian e6e1ed1ed6 Add note duplication (#24) 2026-08-03 13:23:18 +02:00
Yuri Karamian 1a880a4f96 Add open-note trash action (#177) 2026-08-03 10:45:59 +02:00
Yuri Karamian f4e05ed9c4 Add Markdown source highlighting (#173) 2026-08-03 09:00:43 +02:00
Yuri Karamian 4a1045b41e Preserve collapsible code blocks (#138) 2026-08-03 03:26:06 +02:00
Yuri Karamian c305b5f8dd Add Android camera image capture (#131) 2026-08-03 03:13:04 +02:00
Yuri Karamian b5962af466 Add collapsible section shortcut (#129) 2026-08-03 02:56:42 +02:00
Yuri Karamian 13e83b6689 Restore note scroll positions (#127) 2026-08-03 02:43:40 +02:00
Yuri Karamian 432e4f472a Keep local tests out of repository 2026-08-03 02:31:55 +02:00
Yuri Karamian 8731ca4ec1 Support mixed nested lists (#39) 2026-08-03 02:26:39 +02:00
Yuri Karamian 6b165059cc Refine note info path and history 2026-08-03 02:03:58 +02:00
Yuri Karamian 213797a38e Fix mobile outline navigation (#109) 2026-08-03 01:53:29 +02:00
Yuri Karamian fc3e388b80 Refine custom icon size guidance 2026-08-03 01:53:25 +02:00
Yuri Karamian 1c0359e540 Add custom icon size guidance 2026-08-03 01:33:38 +02:00
Yuri Karamian da859e581b Copy active note path from Info panel 2026-08-03 01:16:30 +02:00
Yuri Karamian 763e673738 Show vault location in Info panel 2026-08-03 01:12:50 +02:00
Yuri Karamian 550d3ab3e9 Add per-notebook icons and sync 2026-08-03 01:05:07 +02:00
Yuri Karamian e62404d4c0 Fix dragging selected notes to notebooks 2026-08-03 00:27:18 +02:00
Yuri Karamian 54e051ba02 Add configurable startup view 2026-08-03 00:12:55 +02:00
Yuri Karamian 344728dbe8 Fix macOS traffic lights after window state changes
Reapply the native traffic-light alignment after resize, focus, scale, and theme events so AppKit window-state transitions cannot leave the controls at their default position.
2026-08-02 23:53:26 +02:00
Yuri Karamian 818e9ad82a Merge branch 'nix-build-fix' into 'main'
fix(nix): Update flake.lock and increment fetcher version

Closes #230

See merge request ArkHost/HelixNotes!1
2026-08-02 21:41:20 +00:00
jervw 4931ee3fab fix(nix): Update flake.lock and increment fetcher version 2026-08-02 21:41:20 +00:00
Yuri Karamian 65e09e8fdd fix(mobile): expose notebook actions 2026-07-25 17:51:33 +02:00
Yuri Karamian 64e521d549 fix(ios): poll vault changes to avoid file descriptor exhaustion 2026-07-25 17:14:42 +02:00
Yuri Karamian b8308d89e8 Update README downloads for v1.3.4 2026-07-24 22:38:47 +02:00
31 changed files with 2365 additions and 391 deletions
+3
View File
@@ -13,6 +13,9 @@ bun.lockb
/.svelte-kit
/build
# Local tests
/tests/
# Nix
/result
+11 -9
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.3-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases)
[![Latest Release](https://img.shields.io/badge/release-v1.3.4-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -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.
No cloud, no lock-in.
## Download (v1.3.3)
## Download (v1.3.4)
### Linux
@@ -92,21 +92,23 @@ sudo eopkg it helixnotes
#### 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)
[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
[Download Installer](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64-setup.exe) (Windows 10/11)
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg (Apple Silicon)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_aarch64.dmg) (M-series Macs)
[Download .dmg (Intel)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
>
@@ -116,7 +118,7 @@ sudo eopkg it helixnotes
### Android
[Download APK](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_android.apk)
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
---
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1782959384,
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
"lastModified": 1785571196,
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
"type": "github"
},
"original": {
+3 -3
View File
@@ -26,13 +26,13 @@
inherit pname version;
src = ./.;
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
src = ./.;
fetcherVersion = 3;
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
fetcherVersion = 4;
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
};
nativeBuildInputs = with pkgs; [
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+1
View File
@@ -11,6 +11,7 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-show",
"core:window:allow-is-maximized",
"core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
+35
View File
@@ -596,6 +596,20 @@ pub fn save_note(
Ok(())
}
#[tauri::command]
pub fn duplicate_note(
state: State<'_, AppState>,
path: String,
) -> Result<crate::types::NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault = config.active_vault.as_ref().ok_or("No active vault")?.clone();
drop(config);
let entry = operations::duplicate_note(&path, &vault)?;
index_note_bg(&state, &entry.path);
Ok(entry)
}
#[tauri::command]
pub fn create_note(
state: State<'_, AppState>,
@@ -1231,6 +1245,23 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
// ── Clipboard ──
/// Copy text to the system clipboard through the native backend.
#[cfg(desktop)]
#[tauri::command]
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard
.set_text(text)
.map_err(|e| format!("Failed to copy text: {}", e))
}
#[cfg(mobile)]
#[tauri::command]
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
Err("Text clipboard copy is only supported on desktop".to_string())
}
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
#[cfg(desktop)]
@@ -1381,10 +1412,12 @@ pub fn set_general_settings(
show_line_numbers: bool,
show_link_arrows: bool,
default_view_mode: bool,
new_notes_in_source_mode: bool,
show_tray_icon: bool,
close_to_tray: bool,
enable_wiki_links: bool,
show_note_dates: bool,
startup_view: StartupView,
restore_last_session: bool,
show_all_notes: bool,
show_quick_access: bool,
@@ -1395,6 +1428,7 @@ pub fn set_general_settings(
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.startup_view = startup_view;
config.restore_last_session = restore_last_session;
config.show_all_notes = show_all_notes;
config.show_quick_access = show_quick_access;
@@ -1413,6 +1447,7 @@ pub fn set_general_settings(
config.show_line_numbers = show_line_numbers;
config.show_link_arrows = show_link_arrows;
config.default_view_mode = default_view_mode;
config.new_notes_in_source_mode = new_notes_in_source_mode;
config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray;
config.enable_wiki_links = enable_wiki_links;
+37 -10
View File
@@ -59,16 +59,9 @@ pub fn run() {
.on_page_load(|webview, payload| {
#[cfg(target_os = "macos")]
if webview.label() == "main"
&& matches!(
payload.event(),
tauri::webview::PageLoadEvent::Finished
)
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
{
let window = webview.window();
let window_on_main = window.clone();
let _ = window.run_on_main_thread(move || {
fix_macos_traffic_lights(&window_on_main);
});
queue_macos_traffic_light_fix(&webview.window());
}
#[cfg(not(target_os = "macos"))]
let _ = (webview, payload);
@@ -157,6 +150,7 @@ pub fn run() {
commands::read_note,
commands::save_note,
commands::create_note,
commands::duplicate_note,
commands::create_daily_note,
commands::rename_note,
commands::delete_note,
@@ -177,6 +171,7 @@ pub fn run() {
commands::empty_trash,
commands::load_vault_state,
commands::save_vault_state,
commands::copy_text_to_clipboard,
commands::read_clipboard_image,
commands::copy_image_to_clipboard,
commands::save_image,
@@ -318,9 +313,33 @@ pub fn run() {
}));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
let window_state_builder = tauri_plugin_window_state::Builder::default();
#[cfg(target_os = "linux")]
let window_state_builder = window_state_builder.with_state_flags(
tauri_plugin_window_state::StateFlags::SIZE
| tauri_plugin_window_state::StateFlags::POSITION
| tauri_plugin_window_state::StateFlags::MAXIMIZED
| tauri_plugin_window_state::StateFlags::DECORATIONS
| tauri_plugin_window_state::StateFlags::FULLSCREEN,
);
builder = builder.plugin(window_state_builder.build());
builder = builder.on_window_event(move |window, event| {
#[cfg(target_os = "macos")]
if window.label() == "main"
&& matches!(
event,
tauri::WindowEvent::Resized(_)
| tauri::WindowEvent::Focused(true)
| tauri::WindowEvent::ScaleFactorChanged { .. }
| tauri::WindowEvent::ThemeChanged(_)
)
{
// AppKit can restore the default button Y position after window-state
// changes. Queue this pass so it runs after AppKit finishes its layout.
queue_macos_traffic_light_fix(window);
}
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
// Only hide to tray for the main window
@@ -410,6 +429,14 @@ fn percent_decode(input: &str) -> String {
String::from_utf8_lossy(&output).to_string()
}
#[cfg(target_os = "macos")]
fn queue_macos_traffic_light_fix(window: &tauri::Window) {
let window_on_main = window.clone();
let _ = window.run_on_main_thread(move || {
fix_macos_traffic_lights(&window_on_main);
});
}
#[cfg(target_os = "macos")]
fn fix_macos_traffic_lights(window: &tauri::Window) {
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
+3 -3
View File
@@ -1,14 +1,14 @@
use crate::search::SearchIndex;
use crate::types::AppConfig;
use notify::RecommendedWatcher;
use crate::vault::watcher::VaultWatcher;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use std::sync::Arc;
use std::sync::Mutex;
pub struct AppState {
pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>,
pub watcher: Mutex<Option<VaultWatcher>>,
pub vault_transition: tokio::sync::Mutex<()>,
pub importing: AtomicBool,
pub syncing: AtomicBool,
+32 -7
View File
@@ -6,9 +6,9 @@
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
// manifest itself) is local-only and never synced.
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
// and the manifest itself remain local-only.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
@@ -119,12 +119,12 @@ struct LocalFile {
path: PathBuf,
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
/// folder with unrelated files never drags them into the vault.
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
/// and remote so pointing at a folder with unrelated files never imports them.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/")
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
} else {
rel.ends_with(".md")
}
@@ -724,3 +724,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
);
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::is_synced_relpath;
#[test]
fn syncs_notebook_icon_mapping_and_assets_only() {
for path in [
"Notes/plan.md",
".helixnotes/attachments/notebook-icon.png",
".helixnotes/notebook_icons.json",
] {
assert!(is_synced_relpath(path), "expected {path} to be synced");
}
for path in [
"Notes/image.png",
".helixnotes/sync_state.json",
".helixnotes/notebook_icons.json.bak",
".helixnotes/attachments-old/icon.png",
] {
assert!(!is_synced_relpath(path), "expected {path} to stay local");
}
}
}
+42
View File
@@ -96,6 +96,17 @@ pub struct CustomTheme {
pub colors: CustomThemeColors,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StartupView {
Daily,
QuickAccess,
Tasks,
#[default]
#[serde(other)]
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub vaults: Vec<VaultConfig>,
@@ -186,12 +197,16 @@ pub struct AppConfig {
#[serde(default)]
pub default_view_mode: bool,
#[serde(default)]
pub new_notes_in_source_mode: bool,
#[serde(default)]
pub show_tray_icon: bool,
#[serde(default)]
pub close_to_tray: bool,
#[serde(default = "default_true")]
pub enable_wiki_links: bool,
#[serde(default)]
pub startup_view: StartupView,
#[serde(default)]
pub restore_last_session: bool,
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
#[serde(default)]
@@ -302,9 +317,11 @@ impl Default for AppConfig {
ai_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None,
default_view_mode: false,
new_notes_in_source_mode: false,
show_tray_icon: false,
close_to_tray: false,
enable_wiki_links: true,
startup_view: StartupView::All,
restore_last_session: false,
sync_provider: None,
webdav_url: None,
@@ -469,3 +486,28 @@ pub struct TaskItem {
pub due: 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
);
}
}
+128
View File
@@ -476,6 +476,75 @@ pub fn create_note(
})
}
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
let src = Path::new(path);
if !src.is_file() {
return Err("Note does not exist".to_string());
}
let parent = src
.parent()
.ok_or_else(|| "Note has no parent directory".to_string())?;
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (mut meta, body) = frontmatter::parse_note(&raw, &filename);
let source_title = meta.title.clone();
let base_title = format!("{} copy", source_title.trim_end());
let mut copy_title = base_title.clone();
let mut copy_number = 2;
let mut copy_path = parent.join(format!("{}.md", sanitize_filename(&copy_title)));
while copy_path.exists() {
copy_title = format!("{} {}", base_title, copy_number);
copy_number += 1;
copy_path = parent.join(format!("{}.md", sanitize_filename(&copy_title)));
}
let now = Utc::now();
meta.id = Uuid::new_v4().to_string();
meta.title = copy_title;
meta.pinned = false;
meta.created = now;
meta.modified = now;
let body = retitle_leading_heading(&body, &source_title, &meta.title);
let copy_raw = frontmatter::merge_frontmatter(&raw, &meta, &body);
fs::write(&copy_path, copy_raw).map_err(|e| e.to_string())?;
read_note_entry(&copy_path, Path::new(vault_path))
}
fn retitle_leading_heading(body: &str, old_title: &str, new_title: &str) -> String {
let trimmed = body.trim_start();
let leading_len = body.len() - trimmed.len();
let first_line_end = trimmed.find('\n').unwrap_or(trimmed.len());
let first_line = &trimmed[..first_line_end];
let heading_len = first_line.chars().take_while(|&c| c == '#').count();
if !(1..=6).contains(&heading_len) {
return body.to_string();
}
let Some(heading_title) = first_line[heading_len..].strip_prefix(' ') else {
return body.to_string();
};
if !heading_title.trim().eq_ignore_ascii_case(old_title.trim()) {
return body.to_string();
}
let mut retitled = String::with_capacity(body.len() + new_title.len());
retitled.push_str(&body[..leading_len]);
retitled.push_str(&"#".repeat(heading_len));
retitled.push(' ');
retitled.push_str(new_title);
retitled.push_str(&trimmed[first_line_end..]);
retitled
}
fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
@@ -1237,3 +1306,62 @@ pub fn sanitize_filename(name: &str) -> String {
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::{duplicate_note, helixnotes_dir, load_notebook_icons, set_notebook_icon};
use std::fs;
use uuid::Uuid;
#[test]
fn persists_and_removes_builtin_notebook_icons() {
let vault =
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
set_notebook_icon(&vault_path, "Projects", Some("builtin:briefcase")).unwrap();
let icons = load_notebook_icons(&vault_path).unwrap();
assert_eq!(
icons.get("Projects").map(String::as_str),
Some("builtin:briefcase")
);
set_notebook_icon(&vault_path, "Projects", None).unwrap();
assert!(load_notebook_icons(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
let vault =
std::env::temp_dir().join(format!("helixnotes-duplicate-note-test-{}", Uuid::new_v4()));
let notebook = vault.join("Projects");
fs::create_dir_all(&notebook).unwrap();
let source_path = notebook.join("Project.md");
let source_raw = "---\nid: source-id\ntitle: Project\ntags:\n - work\npinned: true\ncreated: 2020-01-01T00:00:00Z\nmodified: 2020-01-02T00:00:00Z\naliases:\n - Plan\n---\n# Project\n\nOriginal body.\n";
fs::write(&source_path, source_raw).unwrap();
let vault_path = vault.to_string_lossy();
let first = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
let second = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
assert_eq!(first.meta.title, "Project copy");
assert_eq!(second.meta.title, "Project copy 2");
assert_eq!(first.meta.tags, vec!["work"]);
assert!(!first.meta.pinned);
assert_ne!(first.meta.id, "source-id");
assert_ne!(first.meta.id, second.meta.id);
assert_eq!(first.relative_path, "Projects/Project copy.md");
assert_eq!(second.relative_path, "Projects/Project copy 2.md");
let first_raw = fs::read_to_string(&first.path).unwrap();
assert!(first_raw.contains("aliases:\n- Plan"));
assert!(first_raw.contains("# Project copy\n\nOriginal body."));
assert!(!first_raw.contains("\n# Project\n"));
assert_eq!(fs::read_to_string(&source_path).unwrap(), source_raw);
fs::remove_dir_all(vault).unwrap();
}
}
+80 -7
View File
@@ -1,4 +1,4 @@
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
@@ -8,14 +8,57 @@ use crate::state::AppState;
use crate::types::FileEvent;
use crate::vault::operations::helixnotes_dir;
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatcherBackend {
Recommended,
Poll,
}
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
if is_ios {
WatcherBackend::Poll
} else {
WatcherBackend::Recommended
}
}
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
match backend {
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
WatcherBackend::Poll => IOS_POLL_INTERVAL,
}
}
pub enum VaultWatcher {
Recommended(RecommendedWatcher),
Poll(PollWatcher),
}
impl VaultWatcher {
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
match self {
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
}
}
}
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(
tx,
Config::default().with_poll_interval(Duration::from_secs(1)),
)
.map_err(|e| e.to_string())?;
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
let mut watcher = match backend {
WatcherBackend::Recommended => VaultWatcher::Recommended(
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
),
WatcherBackend::Poll => {
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
}
};
watcher
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
@@ -80,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
Ok(watcher)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ios_uses_polling_backend() {
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
}
#[test]
fn other_platforms_keep_recommended_backend() {
assert_eq!(
watcher_backend_for_target(false),
WatcherBackend::Recommended
);
}
#[test]
fn watcher_backends_use_the_expected_intervals() {
assert_eq!(
poll_interval_for_backend(WatcherBackend::Poll),
Duration::from_secs(10)
);
assert_eq!(
poll_interval_for_backend(WatcherBackend::Recommended),
Duration::from_secs(1)
);
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"app": {
"windows": [
{
"label": "main",
"visible": false
}
]
}
}
+13
View File
@@ -16,6 +16,7 @@ import type {
VersionEntry,
TaskItem,
ExternalVaultResult,
StartupView,
} from "./types";
export async function openVault(path: string): Promise<void> {
@@ -140,6 +141,10 @@ export async function createNote(
return invoke("create_note", { notebookRelative, title });
}
export async function duplicateNote(path: string): Promise<NoteEntry> {
return invoke("duplicate_note", { path });
}
export async function createDailyNote(date?: string): Promise<NoteEntry> {
return invoke("create_daily_note", { date: date ?? null });
}
@@ -216,6 +221,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
return invoke("save_vault_state", { vaultState });
}
export async function copyTextToClipboard(text: string): Promise<void> {
return invoke("copy_text_to_clipboard", { text });
}
export async function readClipboardImage(): Promise<number[]> {
return invoke("read_clipboard_image");
}
@@ -260,10 +269,12 @@ export async function setGeneralSettings(
showLineNumbers: boolean,
showLinkArrows: boolean,
defaultViewMode: boolean,
newNotesInSourceMode: boolean,
showTrayIcon: boolean,
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
startupView: StartupView,
restoreLastSession: boolean,
showAllNotes: boolean,
showQuickAccess: boolean,
@@ -285,10 +296,12 @@ export async function setGeneralSettings(
showLineNumbers,
showLinkArrows,
defaultViewMode,
newNotesInSourceMode,
showTrayIcon,
closeToTray,
enableWikiLinks,
showNoteDates,
startupView,
restoreLastSession,
showAllNotes,
showQuickAccess,
+77 -25
View File
@@ -61,12 +61,14 @@
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
import { get } from 'svelte/store';
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
import type { StartupTarget } from '$lib/utils/startup-view';
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
for (const nb of list) {
@@ -81,6 +83,38 @@
let noteList: NoteList;
let editor: Editor;
let unlistenFileChange: (() => void) | null = null;
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
if (target.mode === 'notebook') {
const vault = $appConfig?.active_vault;
const notebook = target.notebookPath === '' && vault
? { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount }
: findNotebookByPath($notebooks, target.notebookPath);
if (!notebook) return false;
const changed = $viewMode !== 'notebook' || $activeNotebook?.relative_path !== notebook.relative_path;
$viewMode = 'notebook';
$activeNotebook = notebook;
$activeTag = null;
if (changed) await noteList?.refresh();
return true;
}
if (target.mode === 'tag') {
const changed = $viewMode !== 'tag' || $activeTag !== target.tag;
$viewMode = 'tag';
$activeTag = target.tag;
$activeNotebook = null;
if (changed) await noteList?.refresh();
return true;
}
const changed = $viewMode !== target.mode || $activeNotebook !== null || $activeTag !== null;
$viewMode = target.mode;
$activeNotebook = null;
$activeTag = null;
if (changed) await noteList?.refresh();
return true;
}
let unlistenOpenFile: (() => void) | null = null;
// Mobile editor header helpers
@@ -415,6 +449,28 @@
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
}
async function trashOpenNote(path: string): Promise<boolean> {
if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false;
try {
await deleteNote(path);
if (Object.hasOwn($noteOrder, path)) {
const { [path]: _, ...rest } = $noteOrder;
$noteOrder = rest;
}
$notes = $notes.filter((note) => note.path !== path);
if ($activeNotePath === path) {
$activeNote = null;
$activeNotePath = null;
}
noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error));
if (isMobile) $mobileView = 'notelist';
return true;
} catch (error) {
console.error('Failed to move open note to Trash:', error);
return false;
}
}
function handleMouseDown(e: MouseEvent) {
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
@@ -433,7 +489,7 @@
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
e.preventDefault();
noteList?.trashActiveNote();
editor?.moveOpenNoteToTrash();
return;
}
@@ -599,9 +655,11 @@
lastTag = state.last_tag ?? null;
} catch (_) {}
const restoreLastSession = $appConfig?.restore_last_session === true;
// On mobile, prefetch last-opened note so first tap is instant
let prefetchPromise: Promise<any> | null = null;
if (isMobile && lastNotePath) {
if (isMobile && restoreLastSession && lastNotePath) {
prefetchPromise = readNote(lastNotePath).catch(() => null);
}
@@ -637,24 +695,19 @@
}, 3000);
}
// Restore the last session (view + open note) if enabled.
if ($appConfig?.restore_last_session) {
const vault = $appConfig?.active_vault;
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
await noteList?.refresh();
} else if (lastViewMode === 'notebook' && lastNotebook) {
const nb = findNotebookByPath($notebooks, lastNotebook);
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
} else if (lastViewMode === 'tag' && lastTag) {
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
} else if (lastViewMode === 'quickaccess') {
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
const startupTarget = resolveStartupTarget({
startupView: $appConfig?.startup_view,
restoreLastSession,
lastViewMode,
lastNotebook,
lastTag
});
if (!(await applyStartupTarget(startupTarget))) {
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
}
// 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 {
const content = await readNote(lastNotePath);
$activeNote = content;
@@ -663,7 +716,6 @@
editor?.loadNote(lastNotePath, content.content);
} catch (_) {}
}
}
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
if (isMobile) {
@@ -918,10 +970,10 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} />
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
</div>
</div>
@@ -979,7 +1031,7 @@
{#if !$notelistCollapsed}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
@@ -998,7 +1050,7 @@
{/if}
<div class="editor-panel">
<Editor bind:this={editor} />
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
{#if $viewMode === 'tasks' && !taskNoteOpened}
<div class="tasks-editor-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
File diff suppressed because it is too large Load Diff
+385
View File
@@ -0,0 +1,385 @@
<script lang="ts">
import { onMount } from 'svelte';
let { src, alt = '', onclose }: {
src: string;
alt?: string;
onclose: () => void;
} = $props();
const MIN_SCALE = 1;
const MAX_SCALE = 5;
const DOUBLE_TAP_MS = 300;
const TAP_MOVE_THRESHOLD = 6;
type Point = { x: number; y: number };
type ViewerGesture =
| { kind: 'pan'; start: Point; pan: Point }
| { kind: 'pinch'; distance: number; imagePoint: Point };
let canvasElement = $state<HTMLDivElement | null>(null);
let imageElement = $state<HTMLImageElement | null>(null);
let closeButton = $state<HTMLButtonElement | null>(null);
let scale = $state(1);
let panX = $state(0);
let panY = $state(0);
let interacting = $state(false);
let gesture: ViewerGesture | null = null;
let touchMoved = false;
let lastTapAt = 0;
let zoomPercent = $derived(Math.round(scale * 100));
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function midpoint(first: Touch, second: Touch): Point {
return {
x: (first.clientX + second.clientX) / 2,
y: (first.clientY + second.clientY) / 2,
};
}
function touchDistance(first: Touch, second: Touch): number {
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
}
function panLimits(nextScale: number): Point {
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
const imageWidth = imageElement?.clientWidth ?? 0;
const imageHeight = imageElement?.clientHeight ?? 0;
return {
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
};
}
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
const limits = panLimits(clampedScale);
scale = clampedScale;
panX = clamp(nextPanX, -limits.x, limits.x);
panY = clamp(nextPanY, -limits.y, limits.y);
}
function resetViewer() {
interacting = false;
gesture = null;
applyTransform(MIN_SCALE, 0, 0);
}
function zoomAt(point: Point, nextScale: number) {
const canvasRect = canvasElement?.getBoundingClientRect();
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
const imageX = (point.x - centerX - panX) / scale;
const imageY = (point.y - centerY - panY) / scale;
applyTransform(
nextScale,
point.x - centerX - imageX * nextScale,
point.y - centerY - imageY * nextScale,
);
}
function beginPan(touch: Touch) {
gesture = {
kind: 'pan',
start: { x: touch.clientX, y: touch.clientY },
pan: { x: panX, y: panY },
};
}
function beginPinch(first: Touch, second: Touch) {
const point = midpoint(first, second);
const canvasRect = canvasElement?.getBoundingClientRect();
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
gesture = {
kind: 'pinch',
distance: Math.max(1, touchDistance(first, second)),
imagePoint: {
x: (point.x - centerX - panX) / scale,
y: (point.y - centerY - panY) / scale,
},
};
}
function handleTouchStart(event: TouchEvent) {
event.preventDefault();
interacting = true;
if (event.touches.length >= 2) {
touchMoved = true;
beginPinch(event.touches[0], event.touches[1]);
return;
}
if (event.touches.length === 1) {
touchMoved = false;
beginPan(event.touches[0]);
}
}
function handleTouchMove(event: TouchEvent) {
event.preventDefault();
if (event.touches.length >= 2) {
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
if (gesture?.kind !== 'pinch') return;
const point = midpoint(event.touches[0], event.touches[1]);
const canvasRect = canvasElement?.getBoundingClientRect();
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
const nextScale = clamp(
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
MIN_SCALE,
MAX_SCALE,
);
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
applyTransform(
nextScale,
point.x - centerX - gesture.imagePoint.x * nextScale,
point.y - centerY - gesture.imagePoint.y * nextScale,
);
touchMoved = true;
return;
}
if (event.touches.length === 1 && gesture?.kind === 'pan') {
const dx = event.touches[0].clientX - gesture.start.x;
const dy = event.touches[0].clientY - gesture.start.y;
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
}
}
function handleTouchEnd(event: TouchEvent) {
event.preventDefault();
if (event.touches.length >= 2) {
beginPinch(event.touches[0], event.touches[1]);
return;
}
if (event.touches.length === 1) {
beginPan(event.touches[0]);
touchMoved = true;
return;
}
interacting = false;
gesture = null;
if (!touchMoved && event.changedTouches.length > 0) {
const now = performance.now();
const touch = event.changedTouches[0];
if (now - lastTapAt <= DOUBLE_TAP_MS) {
if (scale > MIN_SCALE + 0.01) resetViewer();
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
lastTapAt = 0;
} else {
lastTapAt = now;
}
}
}
function handleTouchCancel(event: TouchEvent) {
event.preventDefault();
interacting = false;
gesture = null;
touchMoved = false;
applyTransform(scale, panX, panY);
}
function attachTouchGestures(node: HTMLElement) {
node.addEventListener('touchstart', handleTouchStart, { passive: false });
node.addEventListener('touchmove', handleTouchMove, { passive: false });
node.addEventListener('touchend', handleTouchEnd, { passive: false });
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
return {
destroy() {
node.removeEventListener('touchstart', handleTouchStart);
node.removeEventListener('touchmove', handleTouchMove);
node.removeEventListener('touchend', handleTouchEnd);
node.removeEventListener('touchcancel', handleTouchCancel);
},
};
}
onMount(() => {
closeButton?.focus();
const handleResize = () => applyTransform(scale, panX, panY);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
</script>
<div
class="image-viewer"
role="dialog"
aria-modal="true"
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
tabindex="-1"
onkeydown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
onclose();
}
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="image-viewer-canvas"
bind:this={canvasElement}
use:attachTouchGestures
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
onkeydown={(event) => {
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
}}
>
<img
bind:this={imageElement}
class:interacting
src={src}
alt={alt}
draggable="false"
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
onload={resetViewer}
/>
</div>
<div class="image-viewer-controls">
<button
type="button"
class="image-viewer-reset"
onclick={resetViewer}
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
aria-label={`Reset zoom, currently ${zoomPercent}%`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v6h6" />
</svg>
<span>{zoomPercent}%</span>
</button>
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
</div>
<style>
.image-viewer {
position: fixed;
inset: 0;
z-index: 10000;
background: #06080c;
color: #fff;
overflow: hidden;
overscroll-behavior: contain;
}
.image-viewer-canvas {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.image-viewer-canvas img {
display: block;
max-width: calc(100vw - 24px);
max-height: calc(100dvh - 24px);
object-fit: contain;
transform-origin: center center;
transition: transform 160ms ease-out;
will-change: transform;
-webkit-user-drag: none;
user-select: none;
}
.image-viewer-canvas img.interacting {
transition: none;
}
.image-viewer-controls {
position: absolute;
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.image-viewer-controls button {
min-height: 44px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(20, 23, 30, 0.86);
color: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
pointer-events: auto;
}
.image-viewer-reset {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border-radius: 10px;
font: inherit;
font-size: 13px;
font-weight: 600;
}
.image-viewer-reset:disabled {
opacity: 0.45;
}
.image-viewer-close {
display: grid;
place-items: center;
width: 44px;
padding: 0;
border-radius: 50%;
}
.image-viewer-hint {
position: absolute;
left: 50%;
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
transform: translateX(-50%);
width: max-content;
max-width: calc(100vw - 32px);
padding: 8px 12px;
border-radius: 8px;
background: rgba(20, 23, 30, 0.82);
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
line-height: 1.35;
text-align: center;
pointer-events: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.image-viewer-canvas img {
transition: none;
}
}
</style>
+3 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
import { showInfo } from '$lib/stores/app';
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { openUrl } from '$lib/api';
@@ -144,7 +144,7 @@
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<div class="info-header">
<h2>Info</h2>
<button class="close-btn" onclick={close}>
<button class="close-btn" onclick={close} aria-label="Close info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@@ -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">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Collapsible section</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>.</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
+53 -15
View File
@@ -23,6 +23,7 @@
getNotes,
readNote,
createNote,
duplicateNote,
deleteNote,
renameNote,
saveNote,
@@ -40,16 +41,18 @@
} from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
import { isMobile, isAndroid } from '$lib/platform';
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void;
onBeforeNoteDuplicate?: () => Promise<boolean>;
onNoteCreated?: () => void;
onToggleTask?: (t: TaskItem) => Promise<void>;
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
@@ -265,6 +268,19 @@
if (listContainer) listContainer.scrollTop = 0;
});
// Notes can also be removed from this list by a drop handled in the sidebar.
$effect(() => {
if (selectedPaths.size === 0) return;
const availablePaths = new Set($notes.map((note) => note.path));
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
if (remaining.size === selectedPaths.size) return;
if (remaining.size === 0) {
clearSelection();
} else {
selectedPaths = remaining;
}
});
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
@@ -454,6 +470,27 @@
}
}
async function handleDuplicate(note: NoteEntry) {
contextMenu = null;
try {
if ($activeNotePath === note.path && $editorDirty) {
const saved = await onBeforeNoteDuplicate();
if (!saved) {
console.error('Failed to save note before duplicating');
return;
}
}
const entry = await duplicateNote(note.path);
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear();
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
await selectNote(entry);
} catch (e) {
console.error('Failed to duplicate note:', e);
}
}
function handleListDoubleClick(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
@@ -505,12 +542,6 @@
}
}
// Move the currently open note to the trash (keyboard shortcut from the editor).
export async function trashActiveNote() {
if ($viewMode === 'trash' || !$activeNotePath) return;
const note = $notes.find(n => n.path === $activeNotePath);
if (note) await handleDelete(note);
}
async function handleRestore(note: NoteEntry) {
contextMenu = null;
@@ -1259,11 +1290,10 @@
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else {
e.dataTransfer!.setData('text/plain', note.path);
}
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
? selectedPaths
: [note.path];
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
e.dataTransfer!.effectAllowed = 'move';
}}
ondragover={(e) => {
@@ -1378,7 +1408,7 @@
</div>
{#if contextMenu}
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
<!-- Batch context menu -->
{#if $viewMode === 'trash'}
@@ -1542,6 +1572,12 @@
</svg>
Rename
</button>
<button onclick={() => handleDuplicate(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
</svg>
Duplicate Note
</button>
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
@@ -2284,13 +2320,15 @@
border-radius: 10px;
}
.note-list.mobile .context-menu {
.context-menu.mobile {
min-width: 220px;
border-radius: 12px;
padding: 6px;
max-height: calc(100dvh - 16px);
overflow-y: auto;
}
.note-list.mobile .context-menu button {
.context-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
+61
View File
@@ -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>
+37 -6
View File
@@ -7,7 +7,8 @@
import { getVersion } from '@tauri-apps/api/app';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView } from '$lib/types';
import { normalizeStartupView } from '$lib/utils/startup-view';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
@@ -700,6 +701,7 @@
// General settings
let compactNotes = $state($appConfig?.compact_notes ?? false);
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 timeFormat = $state($appConfig?.time_format ?? 'relative');
let weekStart = $state($appConfig?.week_start ?? 'monday');
@@ -715,6 +717,7 @@
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let newNotesInSourceMode = $state($appConfig?.new_notes_in_source_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
@@ -773,6 +776,7 @@
if ($appConfig) {
$appConfig.compact_notes = compactNotes;
$appConfig.show_note_dates = showNoteDates;
$appConfig.startup_view = startupView;
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat;
$appConfig.week_start = weekStart;
@@ -786,6 +790,7 @@
$appConfig.show_line_numbers = showLineNumbers;
$appConfig.show_link_arrows = showLinkArrows;
$appConfig.default_view_mode = defaultViewMode;
$appConfig.new_notes_in_source_mode = newNotesInSourceMode;
$appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks;
@@ -795,7 +800,7 @@
$appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -975,6 +980,7 @@
if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false;
showNoteDates = $appConfig.show_note_dates ?? true;
startupView = normalizeStartupView($appConfig.startup_view);
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative';
weekStart = $appConfig.week_start ?? 'monday';
@@ -984,6 +990,7 @@
pdfPreview = $appConfig.pdf_preview ?? false;
pdfHeight = $appConfig.pdf_height ?? 600;
titleMode = $appConfig.title_mode ?? 'input';
newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false;
showAllNotes = $appConfig.show_all_notes ?? true;
showQuickAccess = $appConfig.show_quick_access ?? true;
showTasks = $appConfig.show_tasks ?? true;
@@ -1151,6 +1158,29 @@
</div>
{/if}
<div class="settings-section">
<h3>Startup</h3>
<div class="setting-label">
<span class="setting-name">Default view</span>
<span class="setting-desc">Used when session restore is off or the previous view is unavailable.</span>
</div>
<div class="setting-options" style="margin-top: 8px;">
<button class="option-btn" class:active={startupView === 'all'} onclick={() => { startupView = 'all'; saveGeneralSettings(); }}>All Notes</button>
<button class="option-btn" class:active={startupView === 'quickaccess'} onclick={() => { startupView = 'quickaccess'; saveGeneralSettings(); }}>Quick Access</button>
<button class="option-btn" class:active={startupView === 'tasks'} onclick={() => { startupView = 'tasks'; saveGeneralSettings(); }}>Tasks</button>
<button class="option-btn" class:active={startupView === 'daily'} onclick={() => { startupView = 'daily'; saveGeneralSettings(); }}>Daily Notes</button>
</div>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
<div class="settings-section">
<h3>Time Format</h3>
<div class="setting-options">
@@ -1282,17 +1312,18 @@
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</span>
<span class="setting-name">Open new notes in source mode</span>
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
<div class="settings-section">
<h3>Wiki Links & Graph</h3>
<label class="setting-toggle">
+394 -43
View File
@@ -27,6 +27,14 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import type { NotebookEntry } from '$lib/types';
import { isMobile } from '$lib/platform';
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
import NotebookGlyph from './NotebookGlyph.svelte';
import {
NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon,
encodeBuiltinNotebookIcon,
type NotebookIconId
} from '$lib/utils/notebook-icons';
let { onViewChanged = () => {} }: {
onViewChanged?: () => void;
@@ -78,6 +86,8 @@
}
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let iconPickerNotebook = $state<NotebookEntry | null>(null);
let iconPickerElement = $state<HTMLDivElement | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null);
@@ -340,22 +350,32 @@
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
e.preventDefault();
dropTargetPath = null;
const notePath = e.dataTransfer?.getData('text/plain');
if (!notePath) return;
// Don't move if already in this notebook
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
if (noteDir === nb.path) return;
const payload = e.dataTransfer?.getData('text/plain') ?? '';
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
if (notePaths.length === 0) return;
const movedPaths = new Map<string, string>();
for (const notePath of notePaths) {
try {
const newPath = await moveNote(notePath, nb.path);
$notes = $notes.filter(n => n.path !== notePath);
if ($activeNotePath === notePath) {
$activeNotePath = newPath;
$activeNote = await readNote(newPath);
movedPaths.set(notePath, await moveNote(notePath, nb.path));
} catch (e) {
console.error('Failed to move note:', notePath, e);
}
}
if (movedPaths.size === 0) return;
$notes = $notes.filter((note) => !movedPaths.has(note.path));
const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined;
if (activeNewPath) {
$activeNotePath = activeNewPath;
try {
$activeNote = await readNote(activeNewPath);
} catch (e) {
console.error('Failed to reload moved note:', e);
}
}
await refresh();
} catch (e) {
console.error('Failed to move note:', e);
}
}
async function handleNotebookDrop(e: DragEvent, destPath: string) {
@@ -558,21 +578,38 @@
renameInput?.select();
}
async function handleSetIcon(nb: NotebookEntry) {
async function openIconPicker(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = nb;
await tick();
iconPickerElement?.focus();
}
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
try {
const 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 {
const selected = await openDialog({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
});
if (!selected) return;
const filePath = typeof selected === 'string' ? selected : selected;
// Read the file and save as attachment
const data = await readFile(filePath as string);
const fileName = (filePath as string).split('/').pop() || 'icon.png';
const filePath = selected as string;
const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
await setNotebookIcon(nb.relative_path, iconRelative);
$notebookIcons = await getNotebookIcons();
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative };
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
@@ -580,9 +617,12 @@
async function handleRemoveIcon(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = null;
try {
await setNotebookIcon(nb.relative_path, null);
$notebookIcons = await getNotebookIcons();
const icons = { ...$notebookIcons };
delete icons[nb.relative_path];
$notebookIcons = icons;
} catch (e) {
console.error('Failed to remove notebook icon:', e);
}
@@ -590,7 +630,7 @@
function getNotebookIconSrc(nb: NotebookEntry): string | null {
const iconPath = $notebookIcons[nb.relative_path];
if (!iconPath) return null;
if (!iconPath || iconPath.startsWith('builtin:')) return null;
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return null;
return convertFileSrc(`${vaultRoot}/${iconPath}`);
@@ -610,6 +650,14 @@
contextMenu = { x, y, notebook: nb };
}
function openNotebookMenu(e: Event, nb: NotebookEntry) {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
contextMenu = { x, y, notebook: nb };
}
function closeContextMenu() {
contextMenu = null;
}
@@ -642,7 +690,7 @@
}
</script>
<svelte:window onclick={handleWindowClick} />
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile}
@@ -892,8 +940,11 @@
</aside>
{#if contextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
New Sub-notebook
@@ -902,16 +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>
Rename
</button>
<button onclick={() => handleSetIcon(contextMenu!.notebook)}>
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
Set Icon
{$notebookIcons[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'}
</button>
{#if $notebookIcons[contextMenu.notebook.relative_path]}
<button onclick={() => handleRemoveIcon(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
Remove Icon
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
Delete
@@ -919,9 +964,56 @@
</div>
{/if}
{#if iconPickerNotebook}
<div class="icon-picker-overlay" class:mobile={isMobile}>
<button class="icon-picker-backdrop" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}></button>
<div bind:this={iconPickerElement} class="icon-picker" role="dialog" aria-modal="true" aria-label={`Choose an icon for ${iconPickerNotebook.name}`} tabindex="-1">
<header class="icon-picker-header">
<div>
<h3>Notebook icon</h3>
<p>{iconPickerNotebook.name}</p>
</div>
<button class="icon-picker-close" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</header>
<div class="icon-picker-grid">
{#each NOTEBOOK_ICON_OPTIONS as option}
<button
class="icon-picker-option"
class:active={$notebookIcons[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 isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button class="danger" onclick={handleEmptyTrash}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
@@ -935,7 +1027,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<h4>Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions">
@@ -950,6 +1042,7 @@
{@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
{@const iconSrc = getNotebookIconSrc(nb)}
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])}
{#if editingNotebook === nb.path}
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
<input
@@ -965,6 +1058,7 @@
/>
</div>
{:else}
<div class="notebook-row" data-nb-path={nb.path}>
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
@@ -1037,7 +1131,9 @@
{:else}
<span class="chevron-spacer"></span>
{/if}
{#if iconSrc}
{#if builtinIcon}
<span class="notebook-builtin-icon"><NotebookGlyph icon={builtinIcon} size={18} /></span>
{:else if iconSrc}
<img class="notebook-icon" src={iconSrc} alt="" />
{:else if isCollapsed || nb.children.length === 0}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
@@ -1064,6 +1160,17 @@
</span>
{/if}
</button>
{#if isMobile}
<button
type="button"
class="notebook-actions-btn"
aria-label={`Actions for ${nb.name}`}
onclick={(e) => openNotebookMenu(e, nb)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
</button>
{/if}
</div>
{/if}
{#if hasChildren && !isCollapsed}
{#each nb.children as child (child.path)}
@@ -1306,6 +1413,10 @@
padding: 0 2px;
}
.notebook-row {
position: relative;
}
.notebook-item {
display: flex;
align-items: center;
@@ -1378,6 +1489,15 @@
flex-shrink: 0;
}
.notebook-builtin-icon {
width: 20px;
height: 20px;
display: grid;
place-items: center;
color: var(--text-secondary);
flex-shrink: 0;
}
.notebook-name {
flex: 1;
text-align: left;
@@ -1413,6 +1533,28 @@
cursor: grabbing;
}
.notebook-actions-btn {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
color: var(--text-tertiary);
}
.notebook-actions-btn:active {
background: var(--bg-hover);
color: var(--text-primary);
}
.tag-list {
padding: 0 4px;
}
@@ -1481,6 +1623,15 @@
min-width: 140px;
}
.context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 999;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.18);
}
.context-menu button {
display: flex;
align-items: center;
@@ -1508,6 +1659,195 @@
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.icon-picker-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 20px;
}
.icon-picker-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: rgba(8, 10, 18, 0.42);
cursor: default;
}
.icon-picker {
position: relative;
z-index: 1;
width: min(360px, 100%);
max-height: calc(100dvh - 40px);
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-lg);
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border-light);
}
.icon-picker-header h3,
.icon-picker-header p {
margin: 0;
}
.icon-picker-header h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
}
.icon-picker-header p {
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
}
.icon-picker-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.icon-picker-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
padding: 14px;
}
.icon-picker-option {
min-width: 0;
min-height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 3px 6px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.icon-picker-option span {
max-width: 100%;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-option:hover {
border-color: var(--border-color);
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-option.active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
}
.icon-picker-option.active span {
color: var(--accent);
}
.icon-picker-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-light);
}
.icon-picker-recommendation {
flex: 0 0 100%;
margin: 2px 0 0;
color: var(--text-tertiary);
font-size: 11px;
line-height: 1.3;
text-align: center;
}
.icon-picker-actions button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.icon-picker-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-actions button.remove {
background: transparent;
}
.icon-picker-overlay.mobile {
place-items: end center;
padding:
20px
calc(12px + env(safe-area-inset-right, 0px))
calc(12px + env(safe-area-inset-bottom, 0px))
calc(12px + env(safe-area-inset-left, 0px));
}
.icon-picker-overlay.mobile .icon-picker {
width: 100%;
max-height: calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
}
.icon-picker-overlay.mobile .icon-picker-option {
min-height: 64px;
}
.icon-picker-overlay.mobile .icon-picker-actions button {
min-height: 44px;
font-size: 14px;
}
.delete-confirm-overlay {
position: fixed;
inset: 0;
@@ -1635,7 +1975,7 @@
}
.sidebar.mobile .notebook-item {
padding: 12px 12px;
padding: 2px 56px 2px 12px;
min-height: 48px;
gap: 10px;
font-size: 15px;
@@ -1646,6 +1986,11 @@
height: 24px;
}
.sidebar.mobile .notebook-builtin-icon {
width: 24px;
height: 24px;
}
.sidebar.mobile .new-notebook-input {
padding: 4px 12px;
}
@@ -1676,34 +2021,40 @@
display: none;
}
.sidebar.mobile .context-menu {
min-width: 200px;
.context-menu.mobile {
left: calc(12px + env(safe-area-inset-left, 0px)) !important;
right: calc(12px + env(safe-area-inset-right, 0px));
top: auto !important;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
min-width: 0;
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
overflow-y: auto;
border-radius: 12px;
padding: 6px;
}
.sidebar.mobile .context-menu button {
.context-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
border-radius: 8px;
}
.sidebar.mobile .delete-confirm {
.delete-confirm.mobile {
max-width: calc(100vw - 40px);
padding: 24px;
}
.sidebar.mobile .delete-confirm h4 {
.delete-confirm.mobile h4 {
font-size: 16px;
}
.sidebar.mobile .delete-confirm p {
.delete-confirm.mobile p {
font-size: 14px;
}
.sidebar.mobile .delete-confirm-cancel,
.sidebar.mobile .delete-confirm-btn {
.delete-confirm.mobile .delete-confirm-cancel,
.delete-confirm.mobile .delete-confirm-btn {
padding: 10px 20px;
font-size: 14px;
min-height: 44px;
+143
View File
@@ -0,0 +1,143 @@
import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model';
type AssetSourceNormalizer = (source: string) => string;
type MarkdownMark = {
key: string;
priority: number;
open: string;
close: string;
};
const identityAssetSource: AssetSourceNormalizer = (source) => source;
function stringAttr(mark: Mark, name: string): string {
const value = mark.attrs?.[name];
return typeof value === 'string' ? value : '';
}
function markdownMark(mark: Mark, text: string): MarkdownMark | null {
switch (mark.type.name) {
case 'bold':
return { key: 'bold', priority: 10, open: '**', close: '**' };
case 'italic':
return { key: 'italic', priority: 20, open: '*', close: '*' };
case 'strike':
return { key: 'strike', priority: 30, open: '~~', close: '~~' };
case 'underline':
return { key: 'underline', priority: 40, open: '<u>', close: '</u>' };
case 'subscript':
return { key: 'subscript', priority: 50, open: '~', close: '~' };
case 'superscript':
return { key: 'superscript', priority: 60, open: '^', close: '^' };
case 'highlight': {
const color = stringAttr(mark, 'color');
return color
? {
key: `highlight:${color}`,
priority: 70,
open: `<mark data-color="${color}">`,
close: '</mark>',
}
: { key: 'highlight', priority: 70, open: '==', close: '==' };
}
case 'textStyle': {
const color = stringAttr(mark, 'color');
return color
? {
key: `textStyle:${color}`,
priority: 80,
open: `<span style="color: ${color}">`,
close: '</span>',
}
: null;
}
case 'code':
return { key: 'code', priority: 90, open: '`', close: '`' };
case 'link': {
const href = stringAttr(mark, 'href');
return {
key: `link:${href}`,
priority: 100,
open: '[',
close: `](${href})`,
};
}
case 'wikiLink': {
const title = stringAttr(mark, 'title') || text;
const aliased = mark.attrs?.aliased === true || title !== text;
return {
key: `wikiLink:${title}:${aliased}`,
priority: 110,
open: aliased ? `[[${title}|` : '[[',
close: ']]',
};
}
default:
return null;
}
}
function marksForText(node: ProseMirrorNode): MarkdownMark[] {
return node.marks
.map((mark) => markdownMark(mark, node.text || ''))
.filter((mark): mark is MarkdownMark => mark !== null)
.sort((left, right) => left.priority - right.priority || left.key.localeCompare(right.key));
}
function commonMarkCount(active: MarkdownMark[], next: MarkdownMark[]): number {
const length = Math.min(active.length, next.length);
let index = 0;
while (index < length && active[index].key === next[index].key) index += 1;
return index;
}
export function serializeInlineMarkdown(
node: ProseMirrorNode,
normalizeAssetSource: AssetSourceNormalizer = identityAssetSource,
): string {
let result = '';
let activeMarks: MarkdownMark[] = [];
const transitionMarks = (nextMarks: MarkdownMark[]) => {
const shared = commonMarkCount(activeMarks, nextMarks);
for (let index = activeMarks.length - 1; index >= shared; index -= 1) {
result += activeMarks[index].close;
}
for (let index = shared; index < nextMarks.length; index += 1) {
result += nextMarks[index].open;
}
activeMarks = nextMarks;
};
node.forEach((child, _offset, index) => {
if (child.isText) {
transitionMarks(marksForText(child));
let text = child.text || '';
if (index === 0) {
text = text.replace(/^[\t\u2003]+/, (whitespace) => '&emsp;'.repeat(whitespace.length));
}
result += text;
return;
}
transitionMarks([]);
if (child.type.name === 'image') {
const source = normalizeAssetSource(child.attrs.src || '');
if (!source) return;
const alt = child.attrs.alt || '';
const size = child.attrs['data-size'] || child.attrs.size || 'full';
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
if (result) result += '\n';
result += `![${alt}${sizeSuffix}](${source})`;
} else if (child.type.name === 'mathInline') {
result += `$${child.attrs.tex || ''}$`;
} else if (child.type.name === 'hardBreak') {
result += ' \n';
}
});
transitionMarks([]);
return result;
}
+27
View File
@@ -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);
}
+4
View File
@@ -81,6 +81,8 @@ export interface CustomTheme {
colors: CustomThemeColors;
}
export type StartupView = "all" | "quickaccess" | "tasks" | "daily";
export interface AppConfig {
vaults: VaultConfig[];
active_vault: string | null;
@@ -127,9 +129,11 @@ export interface AppConfig {
ai_model: string;
ai_writing_style: string | null;
default_view_mode: boolean;
new_notes_in_source_mode: boolean;
show_tray_icon: boolean;
close_to_tray: boolean;
enable_wiki_links: boolean;
startup_view: StartupView;
restore_last_session: boolean;
sync_provider: string | null;
webdav_url: string | null;
+7
View File
@@ -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);
}
+50
View File
@@ -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;
}
+57
View File
@@ -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;
}
+37 -3
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes, isIOS } from '$lib/platform';
import { darkThemes, isIOS, isMobile } from '$lib/platform';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte';
import NoteWindow from '$lib/components/NoteWindow.svelte';
@@ -15,6 +16,28 @@
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null;
let removeFontSizeListener: (() => void) | null = null;
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
let startupWindowRevealed = false;
const STARTUP_REVEAL_FAILSAFE_MS = 4000;
async function revealStartupWindow() {
if (isMobile || startupWindowRevealed) return;
await tick();
// Force style resolution while the native window is still hidden so its first
// compositor frame already uses the selected theme.
getComputedStyle(document.body).backgroundColor;
try {
await getCurrentWindow().show();
startupWindowRevealed = true;
if (startupRevealTimer) {
clearTimeout(startupRevealTimer);
startupRevealTimer = null;
}
} catch {
// Plain-browser development has no Tauri window to reveal.
}
}
const defaultEditorFontSize = 14;
const minEditorFontSize = 10;
@@ -114,6 +137,9 @@
}
onMount(async () => {
if (!isMobile) {
startupRevealTimer = setTimeout(() => void revealStartupWindow(), STARTUP_REVEAL_FAILSAFE_MS);
}
// Check for note window mode
const params = new URLSearchParams(window.location.search);
const notePath = params.get('note');
@@ -209,6 +235,7 @@
console.error('Failed to apply interface scale:', e);
}
}
await revealStartupWindow();
removeEditorZoomShortcuts = installEditorZoomShortcuts();
// Auto-open last vault if available
@@ -242,14 +269,21 @@
}
} catch {
// First launch or no config
}
} finally {
loading = false;
await revealStartupWindow();
if (startupRevealTimer) {
clearTimeout(startupRevealTimer);
startupRevealTimer = null;
}
}
});
onDestroy(() => {
removeEditorZoomShortcuts?.();
removeFontSizeListener?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
if (startupRevealTimer) clearTimeout(startupRevealTimer);
});
</script>
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import MarkdownIt from 'markdown-it';
import { Schema } from '@tiptap/pm/model';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/editor/markdown.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'markdown.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const { serializeInlineMarkdown } = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
// Link intentionally precedes formatting marks in the schema. This matches the
// mark order produced by the app and reproduces the original per-text-node bug.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { content: 'text*', group: 'block' },
text: { group: 'inline' }
},
marks: {
link: { attrs: { href: {} } },
bold: {},
italic: {},
wikiLink: {
attrs: {
title: { default: null },
aliased: { default: false }
}
}
}
});
const markdown = new MarkdownIt({ html: true, linkify: false, breaks: false });
const bold = schema.marks.bold.create();
const italic = schema.marks.italic.create();
const link = schema.marks.link.create({ href: 'https://example.com' });
function paragraph(segments) {
return schema.node(
'paragraph',
null,
segments.map(({ text, marks }) => schema.text(text, marks))
);
}
function assertMarkdown(segments, expected, expectedHtml) {
const serialized = serializeInlineMarkdown(paragraph(segments));
assert.equal(serialized, expected);
assert.equal(markdown.render(serialized), `${expectedHtml}\n`);
}
test('keeps bold open when a link ends the marked span', () => {
assertMarkdown(
[
{ text: 'Bold ', marks: [bold] },
{ text: 'link', marks: [link, bold] }
],
'**Bold [link](https://example.com)**',
'<p><strong>Bold <a href="https://example.com">link</a></strong></p>'
);
});
test('keeps bold open when a link starts the marked span', () => {
assertMarkdown(
[
{ text: 'link', marks: [link, bold] },
{ text: ' bold', marks: [bold] }
],
'**[link](https://example.com) bold**',
'<p><strong><a href="https://example.com">link</a> bold</strong></p>'
);
});
test('keeps one bold span around a link in the middle', () => {
assertMarkdown(
[
{ text: 'Before ', marks: [bold] },
{ text: 'link', marks: [link, bold] },
{ text: ' after', marks: [bold] }
],
'**Before [link](https://example.com) after**',
'<p><strong>Before <a href="https://example.com">link</a> after</strong></p>'
);
});
test('keeps nested bold and italic marks open across a link', () => {
assertMarkdown(
[
{ text: 'Nested ', marks: [bold, italic] },
{ text: 'link', marks: [link, bold, italic] },
{ text: ' marks', marks: [bold, italic] }
],
'***Nested [link](https://example.com) marks***',
'<p><em><strong>Nested <a href="https://example.com">link</a> marks</strong></em></p>'
);
});
test('preserves wiki-link aliases inside a bold span', () => {
const wikiLink = schema.marks.wikiLink.create({ title: 'Target note', aliased: true });
const serialized = serializeInlineMarkdown(
paragraph([
{ text: 'See ', marks: [bold] },
{ text: 'this note', marks: [wikiLink, bold] },
{ text: ' now', marks: [bold] }
])
);
assert.equal(serialized, '**See [[Target note|this note]] now**');
});