mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
28
Commits
v1.3.4
...
2dd0985ea9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dd0985ea9 | ||
|
|
18bb6bb702 | ||
|
|
fcf48a5b7a | ||
|
|
c7906f4069 | ||
|
|
e6e1ed1ed6 | ||
|
|
1a880a4f96 | ||
|
|
f4e05ed9c4 | ||
|
|
4a1045b41e | ||
|
|
c305b5f8dd | ||
|
|
b5962af466 | ||
|
|
13e83b6689 | ||
|
|
432e4f472a | ||
|
|
8731ca4ec1 | ||
|
|
6b165059cc | ||
|
|
213797a38e | ||
|
|
fc3e388b80 | ||
|
|
1c0359e540 | ||
|
|
da859e581b | ||
|
|
763e673738 | ||
|
|
550d3ab3e9 | ||
|
|
e62404d4c0 | ||
|
|
54e051ba02 | ||
|
|
344728dbe8 | ||
|
|
818e9ad82a | ||
|
|
4931ee3fab | ||
|
|
65e09e8fdd | ||
|
|
64e521d549 | ||
|
|
b8308d89e8 |
@@ -13,6 +13,9 @@ bun.lockb
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local tests
|
||||
/tests/
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,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
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1782959384,
|
||||
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-is-fullscreen",
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::search::SearchIndex;
|
||||
use crate::types::AppConfig;
|
||||
use notify::RecommendedWatcher;
|
||||
use crate::vault::watcher::VaultWatcher;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub watcher: Mutex<Option<VaultWatcher>>,
|
||||
pub vault_transition: tokio::sync::Mutex<()>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
|
||||
+32
-7
@@ -6,9 +6,9 @@
|
||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||
// conflict copies so nothing is ever lost.
|
||||
//
|
||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||
// manifest itself) is local-only and never synced.
|
||||
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
|
||||
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
|
||||
// and the manifest itself remain local-only.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
@@ -119,12 +119,12 @@ struct LocalFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||
/// folder with unrelated files never drags them into the vault.
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
|
||||
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
|
||||
/// and remote so pointing at a folder with unrelated files never imports them.
|
||||
fn is_synced_relpath(rel: &str) -> bool {
|
||||
if rel.starts_with(".helixnotes/") {
|
||||
rel.starts_with(".helixnotes/attachments/")
|
||||
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
|
||||
} else {
|
||||
rel.ends_with(".md")
|
||||
}
|
||||
@@ -724,3 +724,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_synced_relpath;
|
||||
|
||||
#[test]
|
||||
fn syncs_notebook_icon_mapping_and_assets_only() {
|
||||
for path in [
|
||||
"Notes/plan.md",
|
||||
".helixnotes/attachments/notebook-icon.png",
|
||||
".helixnotes/notebook_icons.json",
|
||||
] {
|
||||
assert!(is_synced_relpath(path), "expected {path} to be synced");
|
||||
}
|
||||
|
||||
for path in [
|
||||
"Notes/image.png",
|
||||
".helixnotes/sync_state.json",
|
||||
".helixnotes/notebook_icons.json.bak",
|
||||
".helixnotes/attachments-old/icon.png",
|
||||
] {
|
||||
assert!(!is_synced_relpath(path), "expected {path} to stay local");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ pub struct CustomTheme {
|
||||
pub colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StartupView {
|
||||
Daily,
|
||||
QuickAccess,
|
||||
Tasks,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub vaults: Vec<VaultConfig>,
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +476,75 @@ pub fn create_note(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let parent = src
|
||||
.parent()
|
||||
.ok_or_else(|| "Note has no parent directory".to_string())?;
|
||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||
let filename = src
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (mut meta, body) = frontmatter::parse_note(&raw, &filename);
|
||||
let source_title = meta.title.clone();
|
||||
let base_title = format!("{} copy", source_title.trim_end());
|
||||
let mut copy_title = base_title.clone();
|
||||
let mut copy_number = 2;
|
||||
let mut copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||
|
||||
while copy_path.exists() {
|
||||
copy_title = format!("{} {}", base_title, copy_number);
|
||||
copy_number += 1;
|
||||
copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
meta.id = Uuid::new_v4().to_string();
|
||||
meta.title = copy_title;
|
||||
meta.pinned = false;
|
||||
meta.created = now;
|
||||
meta.modified = now;
|
||||
|
||||
let body = retitle_leading_heading(&body, &source_title, &meta.title);
|
||||
let copy_raw = frontmatter::merge_frontmatter(&raw, &meta, &body);
|
||||
fs::write(©_path, copy_raw).map_err(|e| e.to_string())?;
|
||||
|
||||
read_note_entry(©_path, Path::new(vault_path))
|
||||
}
|
||||
|
||||
fn retitle_leading_heading(body: &str, old_title: &str, new_title: &str) -> String {
|
||||
let trimmed = body.trim_start();
|
||||
let leading_len = body.len() - trimmed.len();
|
||||
let first_line_end = trimmed.find('\n').unwrap_or(trimmed.len());
|
||||
let first_line = &trimmed[..first_line_end];
|
||||
let heading_len = first_line.chars().take_while(|&c| c == '#').count();
|
||||
|
||||
if !(1..=6).contains(&heading_len) {
|
||||
return body.to_string();
|
||||
}
|
||||
|
||||
let Some(heading_title) = first_line[heading_len..].strip_prefix(' ') else {
|
||||
return body.to_string();
|
||||
};
|
||||
if !heading_title.trim().eq_ignore_ascii_case(old_title.trim()) {
|
||||
return body.to_string();
|
||||
}
|
||||
|
||||
let mut retitled = String::with_capacity(body.len() + new_title.len());
|
||||
retitled.push_str(&body[..leading_len]);
|
||||
retitled.push_str(&"#".repeat(heading_len));
|
||||
retitled.push(' ');
|
||||
retitled.push_str(new_title);
|
||||
retitled.push_str(&trimmed[first_line_end..]);
|
||||
retitled
|
||||
}
|
||||
|
||||
fn get_system_locale() -> Locale {
|
||||
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(¬ebook).unwrap();
|
||||
let source_path = notebook.join("Project.md");
|
||||
let source_raw = "---\nid: source-id\ntitle: Project\ntags:\n - work\npinned: true\ncreated: 2020-01-01T00:00:00Z\nmodified: 2020-01-02T00:00:00Z\naliases:\n - Plan\n---\n# Project\n\nOriginal body.\n";
|
||||
fs::write(&source_path, source_raw).unwrap();
|
||||
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let first = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||
let second = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||
|
||||
assert_eq!(first.meta.title, "Project copy");
|
||||
assert_eq!(second.meta.title, "Project copy 2");
|
||||
assert_eq!(first.meta.tags, vec!["work"]);
|
||||
assert!(!first.meta.pinned);
|
||||
assert_ne!(first.meta.id, "source-id");
|
||||
assert_ne!(first.meta.id, second.meta.id);
|
||||
assert_eq!(first.relative_path, "Projects/Project copy.md");
|
||||
assert_eq!(second.relative_path, "Projects/Project copy 2.md");
|
||||
|
||||
let first_raw = fs::read_to_string(&first.path).unwrap();
|
||||
assert!(first_raw.contains("aliases:\n- Plan"));
|
||||
assert!(first_raw.contains("# Project copy\n\nOriginal body."));
|
||||
assert!(!first_raw.contains("\n# Project\n"));
|
||||
assert_eq!(fs::read_to_string(&source_path).unwrap(), source_raw);
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -8,14 +8,57 @@ use crate::state::AppState;
|
||||
use crate::types::FileEvent;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
|
||||
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WatcherBackend {
|
||||
Recommended,
|
||||
Poll,
|
||||
}
|
||||
|
||||
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
|
||||
if is_ios {
|
||||
WatcherBackend::Poll
|
||||
} else {
|
||||
WatcherBackend::Recommended
|
||||
}
|
||||
}
|
||||
|
||||
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
|
||||
match backend {
|
||||
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
|
||||
WatcherBackend::Poll => IOS_POLL_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum VaultWatcher {
|
||||
Recommended(RecommendedWatcher),
|
||||
Poll(PollWatcher),
|
||||
}
|
||||
|
||||
impl VaultWatcher {
|
||||
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
|
||||
match self {
|
||||
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
|
||||
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
tx,
|
||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
|
||||
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
|
||||
let mut watcher = match backend {
|
||||
WatcherBackend::Recommended => VaultWatcher::Recommended(
|
||||
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
|
||||
),
|
||||
WatcherBackend::Poll => {
|
||||
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
|
||||
watcher
|
||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||
@@ -80,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ios_uses_polling_backend() {
|
||||
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_platforms_keep_recommended_backend() {
|
||||
assert_eq!(
|
||||
watcher_backend_for_target(false),
|
||||
WatcherBackend::Recommended
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_backends_use_the_expected_intervals() {
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Poll),
|
||||
Duration::from_secs(10)
|
||||
);
|
||||
assert_eq!(
|
||||
poll_interval_for_backend(WatcherBackend::Recommended),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
ExternalVaultResult,
|
||||
StartupView,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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,32 +695,26 @@
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Restore the last session (view + open note) if enabled.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
||||
$activeTag = null;
|
||||
await noteList?.refresh();
|
||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
||||
} else if (lastViewMode === 'tag' && lastTag) {
|
||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
||||
} else if (lastViewMode === 'quickaccess') {
|
||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
||||
}
|
||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
||||
if (!isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
const startupTarget = resolveStartupTarget({
|
||||
startupView: $appConfig?.startup_view,
|
||||
restoreLastSession,
|
||||
lastViewMode,
|
||||
lastNotebook,
|
||||
lastTag
|
||||
});
|
||||
if (!(await applyStartupTarget(startupTarget))) {
|
||||
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
|
||||
}
|
||||
|
||||
// Reopen the last note only when session restoration is enabled.
|
||||
if (restoreLastSession && !isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||
@@ -918,10 +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">
|
||||
|
||||
+500
-246
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { src, alt = '', onclose }: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 5;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const TAP_MOVE_THRESHOLD = 6;
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type ViewerGesture =
|
||||
| { kind: 'pan'; start: Point; pan: Point }
|
||||
| { kind: 'pinch'; distance: number; imagePoint: Point };
|
||||
|
||||
let canvasElement = $state<HTMLDivElement | null>(null);
|
||||
let imageElement = $state<HTMLImageElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let scale = $state(1);
|
||||
let panX = $state(0);
|
||||
let panY = $state(0);
|
||||
let interacting = $state(false);
|
||||
let gesture: ViewerGesture | null = null;
|
||||
let touchMoved = false;
|
||||
let lastTapAt = 0;
|
||||
let zoomPercent = $derived(Math.round(scale * 100));
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function midpoint(first: Touch, second: Touch): Point {
|
||||
return {
|
||||
x: (first.clientX + second.clientX) / 2,
|
||||
y: (first.clientY + second.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function touchDistance(first: Touch, second: Touch): number {
|
||||
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
||||
}
|
||||
|
||||
function panLimits(nextScale: number): Point {
|
||||
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
|
||||
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
|
||||
const imageWidth = imageElement?.clientWidth ?? 0;
|
||||
const imageHeight = imageElement?.clientHeight ?? 0;
|
||||
return {
|
||||
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
|
||||
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
|
||||
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||||
const limits = panLimits(clampedScale);
|
||||
scale = clampedScale;
|
||||
panX = clamp(nextPanX, -limits.x, limits.x);
|
||||
panY = clamp(nextPanY, -limits.y, limits.y);
|
||||
}
|
||||
|
||||
function resetViewer() {
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
applyTransform(MIN_SCALE, 0, 0);
|
||||
}
|
||||
|
||||
function zoomAt(point: Point, nextScale: number) {
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const imageX = (point.x - centerX - panX) / scale;
|
||||
const imageY = (point.y - centerY - panY) / scale;
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - imageX * nextScale,
|
||||
point.y - centerY - imageY * nextScale,
|
||||
);
|
||||
}
|
||||
|
||||
function beginPan(touch: Touch) {
|
||||
gesture = {
|
||||
kind: 'pan',
|
||||
start: { x: touch.clientX, y: touch.clientY },
|
||||
pan: { x: panX, y: panY },
|
||||
};
|
||||
}
|
||||
|
||||
function beginPinch(first: Touch, second: Touch) {
|
||||
const point = midpoint(first, second);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
gesture = {
|
||||
kind: 'pinch',
|
||||
distance: Math.max(1, touchDistance(first, second)),
|
||||
imagePoint: {
|
||||
x: (point.x - centerX - panX) / scale,
|
||||
y: (point.y - centerY - panY) / scale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = true;
|
||||
if (event.touches.length >= 2) {
|
||||
touchMoved = true;
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
touchMoved = false;
|
||||
beginPan(event.touches[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
|
||||
if (gesture?.kind !== 'pinch') return;
|
||||
const point = midpoint(event.touches[0], event.touches[1]);
|
||||
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||
const nextScale = clamp(
|
||||
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
);
|
||||
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
|
||||
applyTransform(
|
||||
nextScale,
|
||||
point.x - centerX - gesture.imagePoint.x * nextScale,
|
||||
point.y - centerY - gesture.imagePoint.y * nextScale,
|
||||
);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1 && gesture?.kind === 'pan') {
|
||||
const dx = event.touches[0].clientX - gesture.start.x;
|
||||
const dy = event.touches[0].clientY - gesture.start.y;
|
||||
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
|
||||
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
if (event.touches.length >= 2) {
|
||||
beginPinch(event.touches[0], event.touches[1]);
|
||||
return;
|
||||
}
|
||||
if (event.touches.length === 1) {
|
||||
beginPan(event.touches[0]);
|
||||
touchMoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
if (!touchMoved && event.changedTouches.length > 0) {
|
||||
const now = performance.now();
|
||||
const touch = event.changedTouches[0];
|
||||
if (now - lastTapAt <= DOUBLE_TAP_MS) {
|
||||
if (scale > MIN_SCALE + 0.01) resetViewer();
|
||||
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
|
||||
lastTapAt = 0;
|
||||
} else {
|
||||
lastTapAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchCancel(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
interacting = false;
|
||||
gesture = null;
|
||||
touchMoved = false;
|
||||
applyTransform(scale, panX, panY);
|
||||
}
|
||||
|
||||
function attachTouchGestures(node: HTMLElement) {
|
||||
node.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
node.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
node.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('touchstart', handleTouchStart);
|
||||
node.removeEventListener('touchmove', handleTouchMove);
|
||||
node.removeEventListener('touchend', handleTouchEnd);
|
||||
node.removeEventListener('touchcancel', handleTouchCancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
closeButton?.focus();
|
||||
const handleResize = () => applyTransform(scale, panX, panY);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="image-viewer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
|
||||
tabindex="-1"
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onclose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="image-viewer-canvas"
|
||||
bind:this={canvasElement}
|
||||
use:attachTouchGestures
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
onkeydown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
bind:this={imageElement}
|
||||
class:interacting
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable="false"
|
||||
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
|
||||
onload={resetViewer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="image-viewer-reset"
|
||||
onclick={resetViewer}
|
||||
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
|
||||
aria-label={`Reset zoom, currently ${zoomPercent}%`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||
<path d="M3 4v6h6" />
|
||||
</svg>
|
||||
<span>{zoomPercent}%</span>
|
||||
</button>
|
||||
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
|
||||
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.image-viewer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: #06080c;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.image-viewer-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img {
|
||||
display: block;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
transition: transform 160ms ease-out;
|
||||
will-change: transform;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-canvas img.interacting {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls {
|
||||
position: absolute;
|
||||
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-viewer-controls button {
|
||||
min-height: 44px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(20, 23, 30, 0.86);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-viewer-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-viewer-reset:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.image-viewer-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.image-viewer-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 23, 30, 0.82);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image-viewer-canvas img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
getNotes,
|
||||
readNote,
|
||||
createNote,
|
||||
duplicateNote,
|
||||
deleteNote,
|
||||
renameNote,
|
||||
saveNote,
|
||||
@@ -40,16 +41,18 @@
|
||||
} from '$lib/api';
|
||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||
import TasksView from './TasksView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import { isMobile, isAndroid } from '$lib/platform';
|
||||
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||
onNoteMoved?: () => void;
|
||||
onBeforeNoteSwitch?: () => void;
|
||||
onBeforeNoteDuplicate?: () => Promise<boolean>;
|
||||
onNoteCreated?: () => void;
|
||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||
@@ -265,6 +268,19 @@
|
||||
if (listContainer) listContainer.scrollTop = 0;
|
||||
});
|
||||
|
||||
// Notes can also be removed from this list by a drop handled in the sidebar.
|
||||
$effect(() => {
|
||||
if (selectedPaths.size === 0) return;
|
||||
const availablePaths = new Set($notes.map((note) => note.path));
|
||||
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
|
||||
if (remaining.size === selectedPaths.size) return;
|
||||
if (remaining.size === 0) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectedPaths = remaining;
|
||||
}
|
||||
});
|
||||
|
||||
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
|
||||
$effect(() => {
|
||||
$quickAccessPaths;
|
||||
@@ -454,6 +470,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
try {
|
||||
if ($activeNotePath === note.path && $editorDirty) {
|
||||
const saved = await onBeforeNoteDuplicate();
|
||||
if (!saved) {
|
||||
console.error('Failed to save note before duplicating');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await duplicateNote(note.path);
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
|
||||
await selectNote(entry);
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListDoubleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||
@@ -505,12 +542,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Move the currently open note to the trash (keyboard shortcut from the editor).
|
||||
export async function trashActiveNote() {
|
||||
if ($viewMode === 'trash' || !$activeNotePath) return;
|
||||
const note = $notes.find(n => n.path === $activeNotePath);
|
||||
if (note) await handleDelete(note);
|
||||
}
|
||||
|
||||
async function handleRestore(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
@@ -1259,11 +1290,10 @@
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
||||
} else {
|
||||
e.dataTransfer!.setData('text/plain', note.path);
|
||||
}
|
||||
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
|
||||
? selectedPaths
|
||||
: [note.path];
|
||||
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
@@ -1378,7 +1408,7 @@
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||
<!-- Batch context menu -->
|
||||
{#if $viewMode === 'trash'}
|
||||
@@ -1542,6 +1572,12 @@
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={() => handleDuplicate(contextMenu!.note)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
|
||||
</svg>
|
||||
Duplicate Note
|
||||
</button>
|
||||
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||
@@ -2284,13 +2320,15 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu {
|
||||
.context-menu.mobile {
|
||||
min-width: 220px;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { NotebookIconId } from '$lib/utils/notebook-icons';
|
||||
|
||||
let { icon, size = 18 }: { icon: NotebookIconId; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if icon === 'book'}
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
|
||||
{:else if icon === 'folder'}
|
||||
<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2.5h7.5A2.5 2.5 0 0 1 21 9v8.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z" />
|
||||
{:else if icon === 'briefcase'}
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v2h4v-2" />
|
||||
{:else if icon === 'home'}
|
||||
<path d="m3 11 9-8 9 8" />
|
||||
<path d="M5 10v11h14V10M9 21v-7h6v7" />
|
||||
{:else if icon === 'star'}
|
||||
<path d="m12 2 3.1 6.3 6.9 1-5 4.8 1.2 6.9-6.2-3.3L5.8 21 7 14.1l-5-4.8 6.9-1Z" />
|
||||
{:else if icon === 'heart'}
|
||||
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />
|
||||
{:else if icon === 'lightbulb'}
|
||||
<path d="M9 18h6M10 22h4" />
|
||||
<path d="M8.2 15.5A7 7 0 1 1 15.8 15.5c-.7.5-.8 1.1-.8 2.5H9c0-1.4-.1-2-.8-2.5Z" />
|
||||
{:else if icon === 'code'}
|
||||
<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />
|
||||
{:else if icon === 'flask'}
|
||||
<path d="M9 3h6M10 3v6l-5.5 9.5A1.7 1.7 0 0 0 6 21h12a1.7 1.7 0 0 0 1.5-2.5L14 9V3" />
|
||||
<path d="M7 16h10" />
|
||||
{:else if icon === 'graduation'}
|
||||
<path d="m2 9 10-5 10 5-10 5Z" />
|
||||
<path d="M6 11.5V16c3.5 2.7 8.5 2.7 12 0v-4.5M22 9v6" />
|
||||
{:else if icon === 'calendar'}
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" />
|
||||
{:else if icon === 'tasks'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="m7 8 1.5 1.5L11 7M13 9h4M7 14l1.5 1.5L11 13M13 15h4" />
|
||||
{:else if icon === 'archive'}
|
||||
<path d="M4 8v12h16V8M3 4h18v4H3ZM9 12h6" />
|
||||
{:else if icon === 'globe'}
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
{:else if icon === 'plane'}
|
||||
<path d="M22 2 9.5 14.5M22 2l-7 20-4-8-8-4Z" />
|
||||
{:else}
|
||||
<path d="M12 3a9 9 0 1 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a1.5 1.5 0 0 1 0-3h3a6 6 0 0 0 0-12Z" />
|
||||
<path d="M7.5 10h.01M9.5 6.5h.01M14.5 6.5h.01M17 10h.01" />
|
||||
{/if}
|
||||
</svg>
|
||||
@@ -7,7 +7,8 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { 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">
|
||||
|
||||
@@ -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;
|
||||
try {
|
||||
const newPath = await moveNote(notePath, nb.path);
|
||||
$notes = $notes.filter(n => n.path !== notePath);
|
||||
if ($activeNotePath === notePath) {
|
||||
$activeNotePath = newPath;
|
||||
$activeNote = await readNote(newPath);
|
||||
const payload = e.dataTransfer?.getData('text/plain') ?? '';
|
||||
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
|
||||
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
|
||||
if (notePaths.length === 0) return;
|
||||
|
||||
const movedPaths = new Map<string, string>();
|
||||
for (const notePath of notePaths) {
|
||||
try {
|
||||
movedPaths.set(notePath, await moveNote(notePath, nb.path));
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', notePath, e);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to move note:', e);
|
||||
}
|
||||
if (movedPaths.size === 0) return;
|
||||
|
||||
$notes = $notes.filter((note) => !movedPaths.has(note.path));
|
||||
const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined;
|
||||
if (activeNewPath) {
|
||||
$activeNotePath = activeNewPath;
|
||||
try {
|
||||
$activeNote = await readNote(activeNewPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to reload moved note:', e);
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||
@@ -558,21 +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;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
|
||||
type AssetSourceNormalizer = (source: string) => string;
|
||||
|
||||
type MarkdownMark = {
|
||||
key: string;
|
||||
priority: number;
|
||||
open: string;
|
||||
close: string;
|
||||
};
|
||||
|
||||
const identityAssetSource: AssetSourceNormalizer = (source) => source;
|
||||
|
||||
function stringAttr(mark: Mark, name: string): string {
|
||||
const value = mark.attrs?.[name];
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function markdownMark(mark: Mark, text: string): MarkdownMark | null {
|
||||
switch (mark.type.name) {
|
||||
case 'bold':
|
||||
return { key: 'bold', priority: 10, open: '**', close: '**' };
|
||||
case 'italic':
|
||||
return { key: 'italic', priority: 20, open: '*', close: '*' };
|
||||
case 'strike':
|
||||
return { key: 'strike', priority: 30, open: '~~', close: '~~' };
|
||||
case 'underline':
|
||||
return { key: 'underline', priority: 40, open: '<u>', close: '</u>' };
|
||||
case 'subscript':
|
||||
return { key: 'subscript', priority: 50, open: '~', close: '~' };
|
||||
case 'superscript':
|
||||
return { key: 'superscript', priority: 60, open: '^', close: '^' };
|
||||
case 'highlight': {
|
||||
const color = stringAttr(mark, 'color');
|
||||
return color
|
||||
? {
|
||||
key: `highlight:${color}`,
|
||||
priority: 70,
|
||||
open: `<mark data-color="${color}">`,
|
||||
close: '</mark>',
|
||||
}
|
||||
: { key: 'highlight', priority: 70, open: '==', close: '==' };
|
||||
}
|
||||
case 'textStyle': {
|
||||
const color = stringAttr(mark, 'color');
|
||||
return color
|
||||
? {
|
||||
key: `textStyle:${color}`,
|
||||
priority: 80,
|
||||
open: `<span style="color: ${color}">`,
|
||||
close: '</span>',
|
||||
}
|
||||
: null;
|
||||
}
|
||||
case 'code':
|
||||
return { key: 'code', priority: 90, open: '`', close: '`' };
|
||||
case 'link': {
|
||||
const href = stringAttr(mark, 'href');
|
||||
return {
|
||||
key: `link:${href}`,
|
||||
priority: 100,
|
||||
open: '[',
|
||||
close: `](${href})`,
|
||||
};
|
||||
}
|
||||
case 'wikiLink': {
|
||||
const title = stringAttr(mark, 'title') || text;
|
||||
const aliased = mark.attrs?.aliased === true || title !== text;
|
||||
return {
|
||||
key: `wikiLink:${title}:${aliased}`,
|
||||
priority: 110,
|
||||
open: aliased ? `[[${title}|` : '[[',
|
||||
close: ']]',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function marksForText(node: ProseMirrorNode): MarkdownMark[] {
|
||||
return node.marks
|
||||
.map((mark) => markdownMark(mark, node.text || ''))
|
||||
.filter((mark): mark is MarkdownMark => mark !== null)
|
||||
.sort((left, right) => left.priority - right.priority || left.key.localeCompare(right.key));
|
||||
}
|
||||
|
||||
function commonMarkCount(active: MarkdownMark[], next: MarkdownMark[]): number {
|
||||
const length = Math.min(active.length, next.length);
|
||||
let index = 0;
|
||||
while (index < length && active[index].key === next[index].key) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
export function serializeInlineMarkdown(
|
||||
node: ProseMirrorNode,
|
||||
normalizeAssetSource: AssetSourceNormalizer = identityAssetSource,
|
||||
): string {
|
||||
let result = '';
|
||||
let activeMarks: MarkdownMark[] = [];
|
||||
|
||||
const transitionMarks = (nextMarks: MarkdownMark[]) => {
|
||||
const shared = commonMarkCount(activeMarks, nextMarks);
|
||||
for (let index = activeMarks.length - 1; index >= shared; index -= 1) {
|
||||
result += activeMarks[index].close;
|
||||
}
|
||||
for (let index = shared; index < nextMarks.length; index += 1) {
|
||||
result += nextMarks[index].open;
|
||||
}
|
||||
activeMarks = nextMarks;
|
||||
};
|
||||
|
||||
node.forEach((child, _offset, index) => {
|
||||
if (child.isText) {
|
||||
transitionMarks(marksForText(child));
|
||||
let text = child.text || '';
|
||||
if (index === 0) {
|
||||
text = text.replace(/^[\t\u2003]+/, (whitespace) => ' '.repeat(whitespace.length));
|
||||
}
|
||||
result += text;
|
||||
return;
|
||||
}
|
||||
|
||||
transitionMarks([]);
|
||||
|
||||
if (child.type.name === 'image') {
|
||||
const source = normalizeAssetSource(child.attrs.src || '');
|
||||
if (!source) return;
|
||||
const alt = child.attrs.alt || '';
|
||||
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
||||
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
||||
if (result) result += '\n';
|
||||
result += ``;
|
||||
} else if (child.type.name === 'mathInline') {
|
||||
result += `$${child.attrs.tex || ''}$`;
|
||||
} else if (child.type.name === 'hardBreak') {
|
||||
result += ' \n';
|
||||
}
|
||||
});
|
||||
|
||||
transitionMarks([]);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Node as ProseMirrorNode, Schema } from '@tiptap/pm/model';
|
||||
|
||||
export type MixedListName = 'bulletList' | 'taskList';
|
||||
|
||||
export function convertListNode(
|
||||
schema: Schema,
|
||||
listNode: ProseMirrorNode,
|
||||
targetListName: MixedListName,
|
||||
): ProseMirrorNode | null {
|
||||
if (
|
||||
(listNode.type.name !== 'bulletList' && listNode.type.name !== 'taskList') ||
|
||||
listNode.type.name === targetListName
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetListType = schema.nodes[targetListName];
|
||||
const targetItemType = schema.nodes[targetListName === 'taskList' ? 'taskItem' : 'listItem'];
|
||||
if (!targetListType || !targetItemType) return null;
|
||||
|
||||
const itemAttrs = targetListName === 'taskList' ? { checked: false } : null;
|
||||
const convertedItems = listNode.content.content.map((item) =>
|
||||
targetItemType.create(itemAttrs, item.content, item.marks)
|
||||
);
|
||||
|
||||
return targetListType.create(null, convertedItems, listNode.marks);
|
||||
}
|
||||
@@ -81,6 +81,8 @@ export interface CustomTheme {
|
||||
colors: CustomThemeColors;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function encodeNoteDragPaths(paths: Iterable<string>): string {
|
||||
return [...paths].join("\n");
|
||||
}
|
||||
|
||||
export function decodeNoteDragPaths(payload: string): string[] {
|
||||
return payload.split(/\r?\n/).filter((path) => path.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export const NOTEBOOK_ICON_OPTIONS = [
|
||||
{ id: 'book', label: 'Book' },
|
||||
{ id: 'folder', label: 'Folder' },
|
||||
{ id: 'briefcase', label: 'Work' },
|
||||
{ id: 'home', label: 'Home' },
|
||||
{ id: 'star', label: 'Star' },
|
||||
{ id: 'heart', label: 'Heart' },
|
||||
{ id: 'lightbulb', label: 'Ideas' },
|
||||
{ id: 'code', label: 'Code' },
|
||||
{ id: 'flask', label: 'Research' },
|
||||
{ id: 'graduation', label: 'Study' },
|
||||
{ id: 'calendar', label: 'Calendar' },
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
{ id: 'archive', label: 'Archive' },
|
||||
{ id: 'globe', label: 'Globe' },
|
||||
{ id: 'plane', label: 'Travel' },
|
||||
{ id: 'palette', label: 'Creative' }
|
||||
] as const;
|
||||
|
||||
export type NotebookIconId = (typeof NOTEBOOK_ICON_OPTIONS)[number]['id'];
|
||||
|
||||
const BUILTIN_PREFIX = 'builtin:';
|
||||
const BUILTIN_IDS: Record<NotebookIconId, true> = {
|
||||
book: true,
|
||||
folder: true,
|
||||
briefcase: true,
|
||||
home: true,
|
||||
star: true,
|
||||
heart: true,
|
||||
lightbulb: true,
|
||||
code: true,
|
||||
flask: true,
|
||||
graduation: true,
|
||||
calendar: true,
|
||||
tasks: true,
|
||||
archive: true,
|
||||
globe: true,
|
||||
plane: true,
|
||||
palette: true
|
||||
};
|
||||
|
||||
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
|
||||
return `${BUILTIN_PREFIX}${icon}`;
|
||||
}
|
||||
|
||||
export function decodeBuiltinNotebookIcon(value: string | null | undefined): NotebookIconId | null {
|
||||
if (!value?.startsWith(BUILTIN_PREFIX)) return null;
|
||||
const icon = value.slice(BUILTIN_PREFIX.length);
|
||||
return Object.hasOwn(BUILTIN_IDS, icon) ? icon as NotebookIconId : null;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { StartupView } from "../types";
|
||||
|
||||
type RestorableListView = StartupView | "trash";
|
||||
|
||||
export type StartupTarget =
|
||||
| { mode: RestorableListView }
|
||||
| { mode: "notebook"; notebookPath: string }
|
||||
| { mode: "tag"; tag: string };
|
||||
|
||||
export interface StartupState {
|
||||
startupView: unknown;
|
||||
restoreLastSession: boolean;
|
||||
lastViewMode: unknown;
|
||||
lastNotebook: string | null;
|
||||
lastTag: string | null;
|
||||
}
|
||||
|
||||
export function normalizeStartupView(value: unknown): StartupView {
|
||||
switch (value) {
|
||||
case "daily":
|
||||
case "quickaccess":
|
||||
case "tasks":
|
||||
return value;
|
||||
default:
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
function restorableListView(value: unknown): RestorableListView | null {
|
||||
switch (value) {
|
||||
case "all":
|
||||
case "daily":
|
||||
case "quickaccess":
|
||||
case "tasks":
|
||||
case "trash":
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveStartupTarget(state: StartupState): StartupTarget {
|
||||
const fallback: StartupTarget = {
|
||||
mode: normalizeStartupView(state.startupView),
|
||||
};
|
||||
if (!state.restoreLastSession) return fallback;
|
||||
|
||||
if (state.lastViewMode === "notebook" && typeof state.lastNotebook === "string") {
|
||||
return { mode: "notebook", notebookPath: state.lastNotebook };
|
||||
}
|
||||
if (state.lastViewMode === "tag" && state.lastTag) {
|
||||
return { mode: "tag", tag: state.lastTag };
|
||||
}
|
||||
|
||||
const listView = restorableListView(state.lastViewMode);
|
||||
return listView ? { mode: listView } : fallback;
|
||||
}
|
||||
+37
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
removeFontSizeListener?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { Schema } from '@tiptap/pm/model';
|
||||
import { transformWithEsbuild } from 'vite';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/editor/markdown.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const { code } = await transformWithEsbuild(source, 'markdown.ts', {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'esnext'
|
||||
});
|
||||
const { serializeInlineMarkdown } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||
);
|
||||
|
||||
// Link intentionally precedes formatting marks in the schema. This matches the
|
||||
// mark order produced by the app and reproduces the original per-text-node bug.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
paragraph: { content: 'text*', group: 'block' },
|
||||
text: { group: 'inline' }
|
||||
},
|
||||
marks: {
|
||||
link: { attrs: { href: {} } },
|
||||
bold: {},
|
||||
italic: {},
|
||||
wikiLink: {
|
||||
attrs: {
|
||||
title: { default: null },
|
||||
aliased: { default: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const markdown = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||
const bold = schema.marks.bold.create();
|
||||
const italic = schema.marks.italic.create();
|
||||
const link = schema.marks.link.create({ href: 'https://example.com' });
|
||||
|
||||
function paragraph(segments) {
|
||||
return schema.node(
|
||||
'paragraph',
|
||||
null,
|
||||
segments.map(({ text, marks }) => schema.text(text, marks))
|
||||
);
|
||||
}
|
||||
|
||||
function assertMarkdown(segments, expected, expectedHtml) {
|
||||
const serialized = serializeInlineMarkdown(paragraph(segments));
|
||||
assert.equal(serialized, expected);
|
||||
assert.equal(markdown.render(serialized), `${expectedHtml}\n`);
|
||||
}
|
||||
|
||||
test('keeps bold open when a link ends the marked span', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Bold ', marks: [bold] },
|
||||
{ text: 'link', marks: [link, bold] }
|
||||
],
|
||||
'**Bold [link](https://example.com)**',
|
||||
'<p><strong>Bold <a href="https://example.com">link</a></strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps bold open when a link starts the marked span', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'link', marks: [link, bold] },
|
||||
{ text: ' bold', marks: [bold] }
|
||||
],
|
||||
'**[link](https://example.com) bold**',
|
||||
'<p><strong><a href="https://example.com">link</a> bold</strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps one bold span around a link in the middle', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Before ', marks: [bold] },
|
||||
{ text: 'link', marks: [link, bold] },
|
||||
{ text: ' after', marks: [bold] }
|
||||
],
|
||||
'**Before [link](https://example.com) after**',
|
||||
'<p><strong>Before <a href="https://example.com">link</a> after</strong></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps nested bold and italic marks open across a link', () => {
|
||||
assertMarkdown(
|
||||
[
|
||||
{ text: 'Nested ', marks: [bold, italic] },
|
||||
{ text: 'link', marks: [link, bold, italic] },
|
||||
{ text: ' marks', marks: [bold, italic] }
|
||||
],
|
||||
'***Nested [link](https://example.com) marks***',
|
||||
'<p><em><strong>Nested <a href="https://example.com">link</a> marks</strong></em></p>'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves wiki-link aliases inside a bold span', () => {
|
||||
const wikiLink = schema.marks.wikiLink.create({ title: 'Target note', aliased: true });
|
||||
const serialized = serializeInlineMarkdown(
|
||||
paragraph([
|
||||
{ text: 'See ', marks: [bold] },
|
||||
{ text: 'this note', marks: [wikiLink, bold] },
|
||||
{ text: ' now', marks: [bold] }
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(serialized, '**See [[Target note|this note]] now**');
|
||||
});
|
||||
Reference in New Issue
Block a user