mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5ab2a27a | ||
|
|
fef4ece2c0 | ||
|
|
613451f4d8 | ||
|
|
04dee78b27 | ||
|
|
69330bf23c | ||
|
|
4843bcacd5 | ||
|
|
3d28665e1e | ||
|
|
209585a0b4 | ||
|
|
84eab75ec8 | ||
|
|
ce17b38509 | ||
|
|
85c1e1e7d3 | ||
|
|
ebf38e2281 | ||
|
|
32ec62419d | ||
|
|
e46fda91a4 | ||
|
|
62d5388176 | ||
|
|
db2bef892a | ||
|
|
1c46935cc5 | ||
|
|
f0f19125d5 | ||
|
|
828f83d2e3 | ||
|
|
002fdb763e | ||
|
|
d3ff1fb931 | ||
|
|
7951472ae3 | ||
|
|
5cffe8846b | ||
|
|
806e975e31 | ||
|
|
e4232269f6 | ||
|
|
ab759a006d | ||
|
|
ca7f25003c | ||
|
|
08ab0b90f2 | ||
|
|
e275f5c0bb | ||
|
|
683a01627e | ||
|
|
3c11a7f63e | ||
|
|
90a96552d3 | ||
|
|
a710603b0a | ||
|
|
80a6fdca69 | ||
|
|
1dc78ca916 | ||
|
|
3118af1e41 | ||
|
|
a669d96aed | ||
|
|
a0b1fa0481 | ||
|
|
2e8c048a3e | ||
|
|
d2ddc7f0de | ||
|
|
3b88b20c61 | ||
|
|
f8371aaa57 | ||
|
|
c5a45131a5 | ||
|
|
1681fade3b | ||
|
|
4f324ad682 | ||
|
|
c1a9871703 | ||
|
|
83390f5ed2 | ||
|
|
5bf6d8063e | ||
|
|
a1913558b9 | ||
|
|
93e96829f4 | ||
|
|
11673bd8c4 | ||
|
|
4f4e2854a6 | ||
|
|
856ac94a76 | ||
|
|
28263de53d | ||
|
|
b6e855ce3e | ||
|
|
51148efe2e | ||
|
|
32e056dc24 | ||
|
|
c14ad6387d |
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](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.1)
|
||||
## Download (v1.3.2)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -92,23 +92,23 @@ sudo eopkg it helixnotes
|
||||
|
||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.1/HelixNotes_1.3.1_amd64.AppImage)
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_amd64.deb) (Ubuntu 22.04+)
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_android.apk)
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
|
||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -59,6 +59,7 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
Generated
+3
@@ -95,6 +95,9 @@ importers:
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
katex:
|
||||
specifier: ^0.16.28
|
||||
version: 0.16.28
|
||||
|
||||
Generated
+1
-1
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.3.1"
|
||||
version = "1.3.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.1"
|
||||
version = "1.3.2"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
|
||||
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -122,6 +122,14 @@ pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_content_width(state: State<'_, AppState>, width: Option<u32>) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.content_width = width;
|
||||
save_app_config(&config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Notebooks ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1087,17 +1095,28 @@ pub fn set_general_settings(
|
||||
title_mode: String,
|
||||
hide_title_in_body: bool,
|
||||
show_line_numbers: bool,
|
||||
show_link_arrows: bool,
|
||||
default_view_mode: bool,
|
||||
show_tray_icon: bool,
|
||||
close_to_tray: bool,
|
||||
enable_wiki_links: bool,
|
||||
show_note_dates: bool,
|
||||
restore_last_session: bool,
|
||||
show_all_notes: bool,
|
||||
show_quick_access: bool,
|
||||
show_tasks: bool,
|
||||
show_daily_notes: bool,
|
||||
show_trash: bool,
|
||||
) -> Result<(), String> {
|
||||
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.restore_last_session = restore_last_session;
|
||||
config.show_all_notes = show_all_notes;
|
||||
config.show_quick_access = show_quick_access;
|
||||
config.show_tasks = show_tasks;
|
||||
config.show_daily_notes = show_daily_notes;
|
||||
config.show_trash = show_trash;
|
||||
config.time_format = time_format;
|
||||
config.week_start = week_start;
|
||||
config.daily_title_format = daily_title_format;
|
||||
@@ -1108,6 +1127,7 @@ pub fn set_general_settings(
|
||||
config.title_mode = title_mode;
|
||||
config.hide_title_in_body = hide_title_in_body;
|
||||
config.show_line_numbers = show_line_numbers;
|
||||
config.show_link_arrows = show_link_arrows;
|
||||
config.default_view_mode = default_view_mode;
|
||||
config.show_tray_icon = show_tray_icon;
|
||||
config.close_to_tray = close_to_tray;
|
||||
|
||||
@@ -106,6 +106,7 @@ pub fn run() {
|
||||
commands::set_font_family,
|
||||
commands::set_line_height,
|
||||
commands::set_ui_scale,
|
||||
commands::set_content_width,
|
||||
commands::get_notebooks,
|
||||
commands::count_root_notes,
|
||||
commands::create_notebook,
|
||||
|
||||
+26
-11
@@ -339,14 +339,29 @@ impl WebdavClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put_with_dirs(
|
||||
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
|
||||
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
|
||||
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
|
||||
/// to keep it consistent with future listings. (issue #147)
|
||||
fn etag_for(&self, relpath: &str) -> String {
|
||||
self.propfind(relpath, "0")
|
||||
.ok()
|
||||
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// PUT a file (creating parent collections first), then read back its authoritative
|
||||
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
|
||||
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
|
||||
fn upload(
|
||||
&self,
|
||||
relpath: &str,
|
||||
bytes: &[u8],
|
||||
created: &mut HashSet<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<String, String> {
|
||||
self.ensure_dirs(relpath, created)?;
|
||||
self.put(relpath, bytes)
|
||||
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
|
||||
Ok(self.etag_for(relpath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,12 +526,12 @@ fn apply_changes(
|
||||
);
|
||||
} else if local_changed && !remote_changed {
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag.unwrap_or_else(|| re.clone()),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
@@ -556,12 +571,12 @@ fn apply_changes(
|
||||
);
|
||||
let ckey = conflict_relpath(key, &stamp);
|
||||
write_local(vault_path, &ckey, &lbytes)?;
|
||||
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
|
||||
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
ckey,
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&lbytes),
|
||||
remote_etag: cetag.unwrap_or_default(),
|
||||
remote_etag: cetag,
|
||||
},
|
||||
);
|
||||
sum.conflicts += 1;
|
||||
@@ -572,12 +587,12 @@ fn apply_changes(
|
||||
None => {
|
||||
// new local -> upload
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag.unwrap_or_default(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
@@ -586,12 +601,12 @@ fn apply_changes(
|
||||
if me.local_hash != lf.hash {
|
||||
// remote deleted but local edited -> resurrect (upload)
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag.unwrap_or_default(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
|
||||
@@ -72,6 +72,8 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub ui_scale: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub content_width: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub compact_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_note_dates: bool,
|
||||
@@ -95,6 +97,18 @@ pub struct AppConfig {
|
||||
pub hide_title_in_body: bool,
|
||||
#[serde(default)]
|
||||
pub show_line_numbers: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_link_arrows: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_all_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_quick_access: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tasks: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_daily_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_trash: bool,
|
||||
#[serde(default)]
|
||||
pub backup_enabled: bool,
|
||||
#[serde(default = "default_backup_frequency")]
|
||||
@@ -203,6 +217,7 @@ impl Default for AppConfig {
|
||||
font_family: None,
|
||||
line_height: None,
|
||||
ui_scale: None,
|
||||
content_width: None,
|
||||
compact_notes: false,
|
||||
show_note_dates: true,
|
||||
time_format: "relative".to_string(),
|
||||
@@ -215,6 +230,12 @@ impl Default for AppConfig {
|
||||
title_mode: "input".to_string(),
|
||||
hide_title_in_body: false,
|
||||
show_line_numbers: false,
|
||||
show_link_arrows: true,
|
||||
show_all_notes: true,
|
||||
show_quick_access: true,
|
||||
show_tasks: true,
|
||||
show_daily_notes: true,
|
||||
show_trash: true,
|
||||
backup_enabled: false,
|
||||
backup_frequency: "24h".to_string(),
|
||||
backup_max_count: 10,
|
||||
@@ -255,12 +276,16 @@ pub struct VaultState {
|
||||
pub notelist_width: f64,
|
||||
pub sidebar_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub notelist_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub collapsed_notebooks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub notebook_sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub notebook_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub note_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub last_view_mode: String,
|
||||
@@ -285,9 +310,11 @@ impl Default for VaultState {
|
||||
sidebar_width: 220.0,
|
||||
notelist_width: 280.0,
|
||||
sidebar_collapsed: false,
|
||||
notelist_collapsed: false,
|
||||
collapsed_notebooks: Vec::new(),
|
||||
notebook_sort_mode: String::new(),
|
||||
notebook_order: std::collections::HashMap::new(),
|
||||
note_order: std::collections::HashMap::new(),
|
||||
sort_mode: String::new(),
|
||||
last_view_mode: String::new(),
|
||||
last_notebook: None,
|
||||
|
||||
@@ -386,7 +386,26 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
// Fall back to filesystem times when the frontmatter has no created/modified, matching the
|
||||
// note-list scan (read_note_entry_from_str). Without this, a missing `created:` defaults to
|
||||
// Utc::now() here and then gets written to disk on the next save (e.g. a title change),
|
||||
// resetting the create date of imported/frontmatter-less notes. (issue #139)
|
||||
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
|
||||
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
|
||||
if let Ok(fs_meta) = fs::metadata(p) {
|
||||
if !has_modified {
|
||||
if let Ok(modified) = fs_meta.modified() {
|
||||
meta.modified = modified.into();
|
||||
}
|
||||
}
|
||||
if !has_created {
|
||||
if let Ok(created) = fs_meta.created() {
|
||||
meta.created = created.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NoteContent {
|
||||
path: path.to_string(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
|
||||
+25
@@ -961,6 +961,31 @@
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
:root[data-theme="one-dark"] {
|
||||
--bg-primary: #282c34;
|
||||
--bg-secondary: #21252b;
|
||||
--bg-tertiary: #2c313a;
|
||||
--bg-hover: #3e4451;
|
||||
--bg-active: #4b5263;
|
||||
--bg-editor: #282c34;
|
||||
--text-primary: #abb2bf;
|
||||
--text-secondary: #828997;
|
||||
--text-tertiary: #5c6370;
|
||||
--text-accent: #61afef;
|
||||
--border-color: #3e4451;
|
||||
--border-light: #2c313a;
|
||||
--accent: #61afef;
|
||||
--accent-hover: #4d9fdf;
|
||||
--accent-light: color-mix(in srgb, #61afef 12%, transparent);
|
||||
--danger: #e06c75;
|
||||
--danger-hover: #cc5560;
|
||||
--success: #98c379;
|
||||
--warning: #e5c07b;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
|
||||
@@ -52,6 +52,10 @@ export async function setUiScale(scale: number): Promise<void> {
|
||||
return invoke("set_ui_scale", { scale });
|
||||
}
|
||||
|
||||
export async function setContentWidth(width: number | null): Promise<void> {
|
||||
return invoke("set_content_width", { width });
|
||||
}
|
||||
|
||||
export async function getNotebooks(): Promise<NotebookEntry[]> {
|
||||
return invoke("get_notebooks");
|
||||
}
|
||||
@@ -228,12 +232,18 @@ export async function setGeneralSettings(
|
||||
titleMode: string,
|
||||
hideTitleInBody: boolean,
|
||||
showLineNumbers: boolean,
|
||||
showLinkArrows: boolean,
|
||||
defaultViewMode: boolean,
|
||||
showTrayIcon: boolean,
|
||||
closeToTray: boolean,
|
||||
enableWikiLinks: boolean,
|
||||
showNoteDates: boolean,
|
||||
restoreLastSession: boolean,
|
||||
showAllNotes: boolean,
|
||||
showQuickAccess: boolean,
|
||||
showTasks: boolean,
|
||||
showDailyNotes: boolean,
|
||||
showTrash: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_general_settings", {
|
||||
compactNotes,
|
||||
@@ -247,12 +257,18 @@ export async function setGeneralSettings(
|
||||
titleMode,
|
||||
hideTitleInBody,
|
||||
showLineNumbers,
|
||||
showLinkArrows,
|
||||
defaultViewMode,
|
||||
showTrayIcon,
|
||||
closeToTray,
|
||||
enableWikiLinks,
|
||||
showNoteDates,
|
||||
restoreLastSession,
|
||||
showAllNotes,
|
||||
showQuickAccess,
|
||||
showTasks,
|
||||
showDailyNotes,
|
||||
showTrash,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import NoteList from './NoteList.svelte';
|
||||
@@ -15,6 +15,7 @@
|
||||
sidebarWidth,
|
||||
notelistWidth,
|
||||
sidebarCollapsed,
|
||||
notelistCollapsed,
|
||||
collapsedNotebooks,
|
||||
showSearch,
|
||||
showCommandPalette,
|
||||
@@ -48,15 +49,18 @@
|
||||
viewerNote,
|
||||
notebookSortMode,
|
||||
notebookOrder,
|
||||
noteOrder,
|
||||
syncState,
|
||||
platformIsMobile
|
||||
} from '$lib/stores/app';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = $derived($platformIsMobile);
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -199,9 +203,11 @@
|
||||
sidebar_width: $sidebarWidth,
|
||||
notelist_width: $notelistWidth,
|
||||
sidebar_collapsed: $sidebarCollapsed,
|
||||
notelist_collapsed: $notelistCollapsed,
|
||||
collapsed_notebooks: $collapsedNotebooks,
|
||||
notebook_sort_mode: $notebookSortMode,
|
||||
notebook_order: $notebookOrder,
|
||||
note_order: $noteOrder,
|
||||
sort_mode: $sortMode,
|
||||
last_view_mode: $viewMode,
|
||||
last_notebook: $activeNotebook?.relative_path ?? null,
|
||||
@@ -226,6 +232,19 @@
|
||||
persistState();
|
||||
}
|
||||
|
||||
function revealNoteList() {
|
||||
$notelistCollapsed = false;
|
||||
tick().then(() => noteList?.refresh());
|
||||
}
|
||||
|
||||
function toggleNoteList() {
|
||||
const nextCollapsed = !$notelistCollapsed;
|
||||
$notelistCollapsed = nextCollapsed;
|
||||
if (!nextCollapsed) {
|
||||
tick().then(() => noteList?.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||
let taskNoteOpened = $state(false);
|
||||
|
||||
@@ -239,7 +258,14 @@
|
||||
|
||||
function handleViewChanged() {
|
||||
taskNoteOpened = false;
|
||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
|
||||
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
|
||||
if ($notelistCollapsed) {
|
||||
revealNoteList();
|
||||
} else {
|
||||
noteList?.refresh();
|
||||
}
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
}
|
||||
|
||||
@@ -360,6 +386,16 @@
|
||||
navFromPopstate = false;
|
||||
return;
|
||||
}
|
||||
// If a modal is open, close it instead of navigating
|
||||
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
|
||||
$showSettings = false;
|
||||
$showInfo = false;
|
||||
$showSearch = false;
|
||||
$showCommandPalette = false;
|
||||
// Re-push the current state so the next back still works
|
||||
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
|
||||
return;
|
||||
}
|
||||
const state = e.state;
|
||||
const targetDepth = state?.depth ?? 0;
|
||||
historyDepth = targetDepth;
|
||||
@@ -383,88 +419,76 @@
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const code = e.code;
|
||||
if (e.altKey && e.key === 'ArrowLeft') {
|
||||
|
||||
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
|
||||
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||
e.preventDefault();
|
||||
editor?.addLinkFromToolbar();
|
||||
}
|
||||
|
||||
const action = matchAction(e, $keybindings);
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
switch (action) {
|
||||
case 'nav-back':
|
||||
navigateHistory(-1);
|
||||
return;
|
||||
}
|
||||
if (e.altKey && e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
case 'nav-forward':
|
||||
navigateHistory(1);
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyN') {
|
||||
e.preventDefault();
|
||||
case 'new-note':
|
||||
createAndFocusNote();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyN') {
|
||||
e.preventDefault();
|
||||
case 'toggle-theme': {
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
case 'search-vault':
|
||||
$showSearch = true;
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
case 'find-in-note':
|
||||
if ($activeNotePath) {
|
||||
editor?.openNoteSearch();
|
||||
} else {
|
||||
$showSearch = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyP') {
|
||||
e.preventDefault();
|
||||
case 'quick-open':
|
||||
$showCommandPalette = true;
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyS') {
|
||||
e.preventDefault();
|
||||
case 'save':
|
||||
editor?.forceSave();
|
||||
return;
|
||||
}
|
||||
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||
e.preventDefault();
|
||||
editor?.addLinkFromToolbar();
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyM') {
|
||||
e.preventDefault();
|
||||
case 'toggle-source':
|
||||
$sourceMode = !$sourceMode;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyW') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
case 'open-new-window':
|
||||
if ($activeNotePath && $activeNote) {
|
||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
||||
}
|
||||
}
|
||||
if (mod && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
case 'toggle-sidebar':
|
||||
$sidebarCollapsed = !$sidebarCollapsed;
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyE') {
|
||||
e.preventDefault();
|
||||
case 'toggle-notelist':
|
||||
toggleNoteList();
|
||||
return;
|
||||
case 'toggle-focus':
|
||||
$focusMode = !$focusMode;
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyR') {
|
||||
e.preventDefault();
|
||||
case 'toggle-readonly':
|
||||
if ($viewerNote) return; // viewer mode is always read-only
|
||||
$readOnly = !$readOnly;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
case 'fullscreen':
|
||||
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if ($showSettings) $showSettings = false;
|
||||
else if ($showInfo) $showInfo = false;
|
||||
@@ -475,8 +499,7 @@
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
@@ -502,12 +525,23 @@
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$sidebarCollapsed;
|
||||
$notelistCollapsed;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$notebookSortMode;
|
||||
$notebookOrder;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$noteOrder;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$sortMode;
|
||||
$tasksLayout;
|
||||
@@ -534,10 +568,12 @@
|
||||
$sidebarWidth = state.sidebar_width;
|
||||
$notelistWidth = state.notelist_width;
|
||||
$sidebarCollapsed = state.sidebar_collapsed;
|
||||
$notelistCollapsed = state.notelist_collapsed ?? false;
|
||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
|
||||
$notebookOrder = state.notebook_order ?? {};
|
||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
|
||||
$noteOrder = state.note_order ?? {};
|
||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
|
||||
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
|
||||
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
|
||||
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
||||
@@ -895,11 +931,24 @@
|
||||
<ResizeHandle onResize={handleSidebarResize} />
|
||||
{/if}
|
||||
|
||||
{#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(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
{:else}
|
||||
<div class="notelist-restore-panel">
|
||||
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="20" y2="6" />
|
||||
<line x1="8" y1="12" x2="20" y2="12" />
|
||||
<line x1="8" y1="18" x2="20" y2="18" />
|
||||
<polyline points="5 8 3 12 5 16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="editor-panel">
|
||||
@@ -918,7 +967,7 @@
|
||||
{/if}
|
||||
|
||||
<SearchPanel />
|
||||
<CommandPalette />
|
||||
<CommandPalette onNavigate={handleViewChanged} />
|
||||
<SettingsPanel />
|
||||
<InfoPanel />
|
||||
|
||||
@@ -949,6 +998,36 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notelist-restore-panel {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 100%;
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.notelist-restore-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notelist-restore-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
|
||||
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
|
||||
import { setTheme, reindex } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
|
||||
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
|
||||
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
|
||||
|
||||
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
|
||||
$viewMode = mode;
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
$showCommandPalette = false;
|
||||
onNavigate();
|
||||
}
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
@@ -25,6 +38,31 @@
|
||||
$showSearch = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open-all-notes',
|
||||
label: 'Open All Notes',
|
||||
action: () => openView('all')
|
||||
},
|
||||
{
|
||||
id: 'open-quick-access',
|
||||
label: 'Open Quick Access',
|
||||
action: () => openView('quickaccess')
|
||||
},
|
||||
{
|
||||
id: 'open-tasks',
|
||||
label: 'Open Tasks',
|
||||
action: () => openView('tasks')
|
||||
},
|
||||
{
|
||||
id: 'open-daily',
|
||||
label: 'Open Daily Notes',
|
||||
action: () => openView('daily')
|
||||
},
|
||||
{
|
||||
id: 'open-trash',
|
||||
label: 'Open Trash (restore deleted notes)',
|
||||
action: () => openView('trash')
|
||||
},
|
||||
{
|
||||
id: 'theme-light',
|
||||
label: 'Switch to Light Theme',
|
||||
@@ -142,8 +180,7 @@
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
import powershell from 'highlight.js/lib/languages/powershell';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import markdownItMark from 'markdown-it-mark';
|
||||
import markdownItSup from 'markdown-it-sup';
|
||||
@@ -276,8 +277,8 @@
|
||||
if (!q || q.startsWith('http://') || q.startsWith('https://') || q.startsWith('mailto:')) return [];
|
||||
return linkSuggestTitles.filter(e => e.title.toLowerCase().includes(q)).slice(0, 8);
|
||||
});
|
||||
let textContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let tableContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let textContextMenu = $state<{ x: number; y: number; submenuLeft: boolean } | null>(null);
|
||||
let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
|
||||
let tablePickerOpen = $state(false);
|
||||
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
||||
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
|
||||
@@ -498,6 +499,7 @@
|
||||
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register('powershell', powershell);
|
||||
const codeLanguages = [...lowlight.listLanguages(), 'mermaid'].sort();
|
||||
const mdit = MarkdownIt({ html: true, linkify: false, breaks: false })
|
||||
.use(markdownItMark)
|
||||
@@ -1211,10 +1213,20 @@
|
||||
});
|
||||
|
||||
let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null);
|
||||
let codeLangSearch = $state('');
|
||||
let codeLangInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let codeLangFiltered = $derived.by(() => {
|
||||
if (!codeLangSearch) return codeLanguages;
|
||||
const q = codeLangSearch.toLowerCase();
|
||||
return codeLanguages.filter(l => l.includes(q));
|
||||
});
|
||||
|
||||
function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) {
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
codeLangSearch = '';
|
||||
codeLangDropdown = { pos, x: rect.right, y: rect.bottom + 4, current };
|
||||
tick().then(() => codeLangInput?.focus());
|
||||
}
|
||||
|
||||
function selectCodeLang(lang: string) {
|
||||
@@ -1325,6 +1337,45 @@
|
||||
event.preventDefault();
|
||||
const { state, dispatch } = view;
|
||||
const resolvedPos = state.selection.$from;
|
||||
if (event.shiftKey) {
|
||||
for (let depth = resolvedPos.depth; depth > 0; depth--) {
|
||||
const itemNode = resolvedPos.node(depth);
|
||||
if (itemNode.type.name !== 'listItem' && itemNode.type.name !== 'taskItem') continue;
|
||||
const parentListDepth = depth - 1;
|
||||
const itemIndex = resolvedPos.index(parentListDepth);
|
||||
const itemPos = resolvedPos.before(depth);
|
||||
const itemSlice = state.doc.slice(itemPos, itemPos + itemNode.nodeSize);
|
||||
const cursorOffset = resolvedPos.pos - itemPos;
|
||||
const tr = state.tr;
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
if (itemIndex <= 0) return true;
|
||||
const prevPos = resolvedPos.posAtIndex(itemIndex - 1, parentListDepth);
|
||||
tr.delete(itemPos, itemPos + itemNode.nodeSize);
|
||||
const insertAt = tr.mapping.map(prevPos);
|
||||
tr.insert(insertAt, itemSlice.content);
|
||||
const newCursorPos = Math.min(insertAt + cursorOffset, tr.doc.content.size);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
|
||||
dispatch(tr.scrollIntoView());
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentList = resolvedPos.node(parentListDepth);
|
||||
if (itemIndex >= parentList.childCount - 1) return true;
|
||||
const nextPos = resolvedPos.posAtIndex(itemIndex + 1, parentListDepth);
|
||||
const nextNode = state.doc.nodeAt(nextPos);
|
||||
if (!nextNode) return true;
|
||||
const nextSlice = state.doc.slice(nextPos, nextPos + nextNode.nodeSize);
|
||||
tr.delete(nextPos, nextPos + nextNode.nodeSize);
|
||||
const insertAt = tr.mapping.map(itemPos);
|
||||
tr.insert(insertAt, nextSlice.content);
|
||||
const newCursorPos = Math.min(tr.mapping.map(itemPos) + cursorOffset, tr.doc.content.size);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
|
||||
dispatch(tr.scrollIntoView());
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Find the top-level block index
|
||||
const depth = 1; // top-level blocks in doc
|
||||
if (resolvedPos.depth < depth) return true;
|
||||
@@ -1345,7 +1396,7 @@
|
||||
const insertAt = tr.mapping.map(prevPos);
|
||||
tr.insert(insertAt, curSlice.content);
|
||||
const newCursorPos = Math.min(insertAt + cursorOffset, tr.doc.content.size);
|
||||
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos)));
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
|
||||
dispatch(tr.scrollIntoView());
|
||||
} else {
|
||||
if (parentIndex >= state.doc.childCount - 1) return true;
|
||||
@@ -1360,7 +1411,7 @@
|
||||
const insertAt = tr.mapping.map(parentPos);
|
||||
tr.insert(insertAt, nextSlice.content);
|
||||
const newCursorPos = Math.min(tr.mapping.map(parentPos) + cursorOffset, tr.doc.content.size);
|
||||
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos)));
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(newCursorPos)));
|
||||
dispatch(tr.scrollIntoView());
|
||||
}
|
||||
return true;
|
||||
@@ -1422,7 +1473,7 @@
|
||||
if (!pre) return false;
|
||||
// Check if click is in the top-right corner (language button area)
|
||||
const rect = pre.getBoundingClientRect();
|
||||
if (event.clientX < rect.right - 70 || event.clientY > rect.top + 30) return false;
|
||||
if (event.clientX < rect.right - 100 || event.clientY > rect.top + 30) return false;
|
||||
// Find the code block position
|
||||
const pos = view.posAtDOM(pre, 0);
|
||||
const resolved = view.state.doc.resolve(pos);
|
||||
@@ -1443,10 +1494,10 @@
|
||||
// Virtual trigger for dropdown positioning
|
||||
const triggerEl = document.createElement('div');
|
||||
triggerEl.getBoundingClientRect = () => ({
|
||||
top: rect.top + 6, bottom: rect.top + 26,
|
||||
left: rect.right - 60, right: rect.right - 6,
|
||||
width: 54, height: 20,
|
||||
x: rect.right - 60, y: rect.top + 6,
|
||||
top: rect.top + 5, bottom: rect.top + 25,
|
||||
left: rect.right - 100, right: rect.right - 34,
|
||||
width: 66, height: 20,
|
||||
x: rect.right - 100, y: rect.top + 5,
|
||||
toJSON() { return this; },
|
||||
});
|
||||
openCodeLangDropdown(cbPos + 1, cbNode.attrs.language || '', triggerEl as any);
|
||||
@@ -2620,6 +2671,61 @@
|
||||
return result.replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
||||
}
|
||||
|
||||
function tableToMarkdown(table: any): string {
|
||||
const rows: string[][] = [];
|
||||
let hasHeader = false;
|
||||
table.forEach((row: any) => {
|
||||
if (row.type.name !== 'tableRow') return;
|
||||
const cells: string[] = [];
|
||||
row.forEach((cell: any) => {
|
||||
if (cell.type.name === 'tableHeader') hasHeader = true;
|
||||
const cellText: string[] = [];
|
||||
cell.forEach((p: any) => {
|
||||
cellText.push(serializeInline(p));
|
||||
});
|
||||
cells.push(cellText.join(' ').replace(/\|/g, '\\|').replace(/\n/g, ' '));
|
||||
});
|
||||
rows.push(cells);
|
||||
});
|
||||
if (rows.length === 0) return '';
|
||||
const colCount = Math.max(...rows.map(r => r.length));
|
||||
const lines: string[] = [];
|
||||
rows.forEach((row, i) => {
|
||||
lines.push('| ' + row.join(' | ') + ' |');
|
||||
if (i === 0 && hasHeader) {
|
||||
lines.push('| ' + Array(colCount).fill('---').join(' | ') + ' |');
|
||||
}
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function resetTableToMarkdown() {
|
||||
if (!editor) return;
|
||||
const { state } = editor;
|
||||
let { tr } = state;
|
||||
let changed = false;
|
||||
state.doc.descendants((node: any, pos: number) => {
|
||||
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
|
||||
if (node.attrs.backgroundColor || (node.attrs.colspan && node.attrs.colspan > 1) || (node.attrs.rowspan && node.attrs.rowspan > 1)) {
|
||||
tr = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
backgroundColor: null,
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (changed) {
|
||||
editor.view.dispatch(tr);
|
||||
$editorDirty = true;
|
||||
autoSave();
|
||||
}
|
||||
closeTableContextMenu();
|
||||
}
|
||||
|
||||
function serializeNode(node: any): string {
|
||||
switch (node.type.name) {
|
||||
case 'paragraph': {
|
||||
@@ -2676,12 +2782,24 @@
|
||||
case 'pageBreak':
|
||||
return '<div style="page-break-after: always;"></div>\n';
|
||||
case 'table': {
|
||||
// Preserve tables as raw HTML
|
||||
// Check if any cell has styling (background color) or if there are merged cells
|
||||
let hasStyling = false;
|
||||
node.descendants((child: any) => {
|
||||
if (child.type.name === 'tableCell' || child.type.name === 'tableHeader') {
|
||||
if (child.attrs.backgroundColor) hasStyling = true;
|
||||
if (child.attrs.colspan && child.attrs.colspan > 1) hasStyling = true;
|
||||
if (child.attrs.rowspan && child.attrs.rowspan > 1) hasStyling = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (hasStyling) {
|
||||
const tempDiv = document.createElement('div');
|
||||
const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
|
||||
tempDiv.appendChild(frag);
|
||||
return tempDiv.innerHTML + '\n';
|
||||
}
|
||||
return tableToMarkdown(node) + '\n';
|
||||
}
|
||||
case 'pdfEmbed': {
|
||||
const src = node.attrs.src || '';
|
||||
const name = node.attrs.name || '';
|
||||
@@ -2863,11 +2981,63 @@
|
||||
// Only steal focus when navigating (Enter/Shift+Enter), not while typing
|
||||
if (focusTextarea) sourceElement.focus();
|
||||
sourceElement.setSelectionRange(match.from, match.to);
|
||||
// Scroll the match into view
|
||||
const linesBefore = sourceContent.substring(0, match.from).split('\n').length;
|
||||
scrollSourceToOffset(match.from);
|
||||
}
|
||||
|
||||
// Centre the source textarea's viewport on the line containing `offset`.
|
||||
function scrollSourceToOffset(offset: number) {
|
||||
if (!sourceElement) return;
|
||||
const linesBefore = sourceContent.substring(0, offset).split('\n').length;
|
||||
const lineHeight = parseFloat(getComputedStyle(sourceElement).lineHeight) || 20;
|
||||
const targetScroll = (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2;
|
||||
sourceElement.scrollTop = Math.max(0, targetScroll);
|
||||
sourceElement.scrollTop = Math.max(0, (linesBefore - 1) * lineHeight - sourceElement.clientHeight / 2);
|
||||
}
|
||||
|
||||
// ── Caret carry-over between the rich editor and the markdown source view (issue #125) ──
|
||||
// The caret is a ProseMirror doc position in one mode and a character offset in the other,
|
||||
// and the markdown<->doc conversion is lossy, so rather than invert the conversion we anchor
|
||||
// on VISIBLE TEXT. `scanAlign` walks the markdown source against the doc's plain text (its
|
||||
// non-whitespace characters); markup characters don't match the next expected visible char and
|
||||
// are skipped, so the scan locks onto real words and self-resynchronises. This stays correct
|
||||
// even at the end of a long note and is decoupled from how serialization happens to format.
|
||||
// - stopAtNw >= 0: stop once that many visible chars have matched -> srcOffset is the source
|
||||
// position just past them (maps a doc caret to a source offset).
|
||||
// - limit >= 0: stop at source index `limit` -> nwCount is how many visible chars precede it
|
||||
// (maps a source caret to a doc visible-char count).
|
||||
function scanAlign(source: string, docNonWs: string, opts: { limit?: number; stopAtNw?: number }): { srcOffset: number; nwCount: number } {
|
||||
const limit = opts.limit ?? -1;
|
||||
const stopAtNw = opts.stopAtNw ?? -1;
|
||||
if (stopAtNw === 0) return { srcOffset: 0, nwCount: 0 };
|
||||
let k = 0;
|
||||
let i = 0;
|
||||
for (; i < source.length; i++) {
|
||||
if (limit >= 0 && i >= limit) break;
|
||||
const c = source[i];
|
||||
if (c === ' ' || c === '\n' || c === '\t' || c === '\r') continue;
|
||||
if (c === docNonWs[k]) {
|
||||
k++;
|
||||
if (stopAtNw >= 0 && k >= stopAtNw) { i++; break; }
|
||||
}
|
||||
}
|
||||
return { srcOffset: i, nwCount: k };
|
||||
}
|
||||
|
||||
// The whole editor doc as plain text with whitespace removed (the alignment alphabet).
|
||||
function docNonWhitespace(): string {
|
||||
if (!editor) return '';
|
||||
return editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n', '').replace(/\s/g, '');
|
||||
}
|
||||
|
||||
// Editor doc position just after the `targetNw`-th non-whitespace character.
|
||||
function docPosForNonWsCount(doc: any, targetNw: number): number {
|
||||
if (targetNw <= 0) return 0;
|
||||
const nwAt = (pos: number) => doc.textBetween(0, pos, '\n', '').replace(/\s/g, '').length;
|
||||
let lo = 0;
|
||||
let hi = doc.content.size;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (nwAt(mid) >= targetNw) hi = mid; else lo = mid + 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
function resetSourceHistory(content: string) {
|
||||
@@ -3213,6 +3383,21 @@
|
||||
// Pre-process: fix image paths with multiple leading slashes (from broken saves)
|
||||
src = src.replace(/!\[([^\]]*)\]\((\/{2,})(home\/)/g, ';
|
||||
|
||||
// Pre-process: normalize link/image destinations for strict-CommonMark markdown-it.
|
||||
// Handles angle-bracket destinations <url>, optional titles ("..."/'...'/(...)), and the
|
||||
// non-standard %20 separator some exporters (e.g. Notesnook) emit between url and title.
|
||||
// Emits clean [label](encoded-url); titles are dropped (we never serialize them anyway).
|
||||
src = src.replace(
|
||||
/(!?)(\[[^\]]*\])\(<([^>]*)>(?:(?:\s|%20)*(?:"[^"]*"|'[^']*'|\([^)]*\)))?(?:\s|%20)*\)/g,
|
||||
(_m, bang, label, url) => `${bang}${label}(${url.replace(/ /g, '%20')})`
|
||||
);
|
||||
// Bare destination with a trailing quoted title: [label](url "title") -> drop title, encode spaces.
|
||||
// Stops our own space-encoder below from mangling valid CommonMark titled links.
|
||||
src = src.replace(
|
||||
/(!?)(\[[^\]]*\])\(([^()<>]*?)(?:\s|%20)+(?:"[^"]*"|'[^']*')(?:\s|%20)*\)/g,
|
||||
(_m, bang, label, url) => `${bang}${label}(${url.replace(/ /g, '%20')})`
|
||||
);
|
||||
|
||||
// Pre-process: percent-encode spaces in image URLs so markdown-it parses them correctly
|
||||
src = src.replace(/!\[([^\]]*)\]\(([^)]*\s[^)]*)\)/g, (match, alt, url) => {
|
||||
return `})`;
|
||||
@@ -3280,6 +3465,11 @@
|
||||
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- <tiptask checked="false">$2</tiptask>');
|
||||
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false"> </tiptask>');
|
||||
|
||||
// Pre-process: strip list-separator comments before markdown-it.
|
||||
// markdown-it treats <!-- --> as an HTML block start, swallowing the
|
||||
// next line (e.g. an image) as raw HTML instead of parsing it.
|
||||
src = src.replace(/<!-- -->/g, '\n');
|
||||
|
||||
// Pre-process: preserve blank lines before image-only lines
|
||||
// markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph.
|
||||
// Insert a <div> marker that markdown-it passes through (html: true), then convert to <p></p>
|
||||
@@ -3331,7 +3521,7 @@
|
||||
if (pendingContent !== null) {
|
||||
createEditor(pendingContent);
|
||||
pendingContent = null;
|
||||
} else if (isMobile) {
|
||||
} else if (isMobile && !lastSourceMode) {
|
||||
createEditor('');
|
||||
}
|
||||
}
|
||||
@@ -3392,7 +3582,12 @@
|
||||
|
||||
function createEditor(content: string) {
|
||||
if (!editorElement) return;
|
||||
destroyEditor();
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
editor = null;
|
||||
}
|
||||
mathObserver?.disconnect();
|
||||
mathObserver = null;
|
||||
|
||||
isLargeDoc = content.length > LARGE_DOC_CHARS;
|
||||
const html = markdownToHtml(content);
|
||||
@@ -3929,12 +4124,28 @@
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
const menuWidth = 220;
|
||||
const menuHeight = 520;
|
||||
const menuHeight = 600;
|
||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||
if (x < 4) x = 4;
|
||||
if (y < 4) y = 4;
|
||||
tableContextMenu = { x, y };
|
||||
let hasStyling = false;
|
||||
if (editor) {
|
||||
const pos = editor.view.posAtDOM(event.target as Node, 0);
|
||||
let resolved = editor.state.doc.resolve(pos);
|
||||
for (let d = resolved.depth; d >= 0; d--) {
|
||||
if (resolved.node(d).type.name === 'table') {
|
||||
resolved.node(d).descendants((n: any) => {
|
||||
if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
|
||||
if (n.attrs.backgroundColor || (n.attrs.colspan > 1) || (n.attrs.rowspan > 1)) hasStyling = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tableContextMenu = { x, y, hasStyling };
|
||||
return;
|
||||
}
|
||||
// Show text context menu for any right-click in editor
|
||||
@@ -3949,7 +4160,9 @@
|
||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||
if (x < 4) x = 4;
|
||||
if (y < 4) y = 4;
|
||||
textContextMenu = { x, y };
|
||||
const submenuWidth = 150;
|
||||
const submenuLeft = x + menuWidth + submenuWidth > window.innerWidth;
|
||||
textContextMenu = { x, y, submenuLeft };
|
||||
}
|
||||
|
||||
function closeTextContextMenu() {
|
||||
@@ -4615,27 +4828,53 @@
|
||||
if (!loadedPath) return;
|
||||
|
||||
if (isSource && !lastSourceMode) {
|
||||
// Switching TO source: extract markdown from editor
|
||||
// Switching TO source: extract markdown, then drop the caret on the same word (#125).
|
||||
const caretNonWs = editor
|
||||
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
||||
: 0;
|
||||
const docNonWs = docNonWhitespace();
|
||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
||||
resetSourceHistory(sourceContent);
|
||||
lastSourceMode = true;
|
||||
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
||||
tick().then(() => {
|
||||
if (!sourceElement) return;
|
||||
if (!isMobile) sourceElement.focus();
|
||||
sourceElement.setSelectionRange(target, target);
|
||||
scrollSourceToOffset(target);
|
||||
});
|
||||
} else if (!isSource && lastSourceMode) {
|
||||
lastSourceMode = false;
|
||||
// Capture the source caret before the textarea is torn down (#125).
|
||||
const srcOffset = sourceElement ? sourceElement.selectionStart : 0;
|
||||
const srcText = sourceContent;
|
||||
// Re-anchor the caret in the rich editor once it holds the new content.
|
||||
const restoreRichCaret = () => {
|
||||
if (!editor) return;
|
||||
const nwBefore = scanAlign(srcText, docNonWhitespace(), { limit: srcOffset }).nwCount;
|
||||
const pos = Math.min(docPosForNonWsCount(editor.state.doc, nwBefore), editor.state.doc.content.size);
|
||||
const sel = TextSelection.near(editor.state.doc.resolve(pos));
|
||||
editor.view.dispatch(editor.state.tr.setSelection(sel).scrollIntoView());
|
||||
if (!isMobile) editor.view.focus();
|
||||
requestAnimationFrame(() => editor?.commands.scrollIntoView());
|
||||
};
|
||||
if (isMobile) {
|
||||
// Mobile: editor stays in DOM, just update its content
|
||||
const content = sourceContent || ($activeNote?.content ?? '');
|
||||
const content = srcText || ($activeNote?.content ?? '');
|
||||
if (editor) {
|
||||
ignoreNextUpdate = true;
|
||||
editor.commands.setContent(markdownToHtml(content));
|
||||
tick().then(restoreRichCaret);
|
||||
}
|
||||
} else {
|
||||
// Desktop: destroy old editor (its DOM element is gone),
|
||||
// wait for DOM to swap textarea→div, then create editor on new element.
|
||||
destroyEditor();
|
||||
const content = sourceContent || ($activeNote?.content ?? '');
|
||||
const content = srcText || ($activeNote?.content ?? '');
|
||||
tick().then(() => {
|
||||
if (editorElement && !editor) {
|
||||
createEditor(content);
|
||||
restoreRichCaret();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5260,22 +5499,30 @@
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg>
|
||||
File
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
||||
Horizontal Rule
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleCodeBlock().run(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||
Code Block
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; openSecretInsert(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Secret
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleBlockquote().run(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
|
||||
Quote
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; tablePickerOpen = true; }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
|
||||
Table
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; insertDetails(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
||||
Collapsible Section
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
||||
Horizontal Rule
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; openSecretInsert(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Secret
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -5334,6 +5581,53 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Indent / Outdent (no Tab key on mobile soft keyboards) -->
|
||||
<button class="fmt-btn" onclick={() => {
|
||||
if (!editor) return;
|
||||
// Try list indent first - run() returns true if it succeeded
|
||||
const sank = editor.chain().focus().sinkListItem('listItem').run();
|
||||
if (!sank) {
|
||||
const sankTask = editor.chain().focus().sinkListItem('taskItem').run();
|
||||
if (!sankTask && editor.state.selection.empty) {
|
||||
editor.chain().focus().insertContent('\t').run();
|
||||
}
|
||||
}
|
||||
}} title="Indent">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="4" x2="21" y2="4"/><line x1="11" y1="9" x2="21" y2="9"/><line x1="11" y1="14" x2="21" y2="14"/><line x1="3" y1="19" x2="21" y2="19"/><polyline points="3 9 7 11.5 3 14"/></svg>
|
||||
</button>
|
||||
<button class="fmt-btn" onclick={() => {
|
||||
if (!editor) return;
|
||||
const lifted = editor.chain().focus().liftListItem('listItem').run();
|
||||
if (!lifted) {
|
||||
const liftedTask = editor.chain().focus().liftListItem('taskItem').run();
|
||||
if (!liftedTask && editor.state.selection.empty) {
|
||||
// Remove leading tab/spaces from current line
|
||||
const { from } = editor.state.selection;
|
||||
const pos = editor.state.doc.resolve(from);
|
||||
const lineStart = pos.start(pos.depth);
|
||||
const lineText = editor.state.doc.textBetween(lineStart, pos.end(pos.depth));
|
||||
if (lineText.startsWith('\t')) {
|
||||
editor.chain().focus().command(({ tr }) => {
|
||||
tr.delete(lineStart, lineStart + 1);
|
||||
return true;
|
||||
}).run();
|
||||
} else if (lineText.startsWith(' ')) {
|
||||
editor.chain().focus().command(({ tr }) => {
|
||||
tr.delete(lineStart, lineStart + 4);
|
||||
return true;
|
||||
}).run();
|
||||
} else if (lineText.startsWith(' ')) {
|
||||
editor.chain().focus().command(({ tr }) => {
|
||||
tr.delete(lineStart, lineStart + 2);
|
||||
return true;
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}} title="Outdent">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="4" x2="21" y2="4"/><line x1="11" y1="9" x2="21" y2="9"/><line x1="11" y1="14" x2="21" y2="14"/><line x1="3" y1="19" x2="21" y2="19"/><polyline points="7 9 3 11.5 7 14"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="fmt-sep"></div>
|
||||
|
||||
<!-- Highlight -->
|
||||
@@ -5386,30 +5680,14 @@
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg>
|
||||
File
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; tablePickerOpen = true; }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
|
||||
Table
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
||||
Horizontal Rule
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleCodeBlock().run(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m10 9-3 3 3 3"/><path d="m14 15 3-3-3-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||
Code Block
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; openSecretInsert(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Secret
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleBlockquote().run(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
|
||||
Quote
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; insertDetails(); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
||||
Collapsible Section
|
||||
</button>
|
||||
<button onclick={() => { insertDropdown = false; insertTimestamp('datetime'); }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>
|
||||
Date & Time
|
||||
@@ -5784,7 +6062,7 @@
|
||||
<svg class="submenu-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg>
|
||||
</button>
|
||||
{#if ctxHeadingSubmenu}
|
||||
<div class="text-ctx-submenu">
|
||||
<div class="text-ctx-submenu" class:flip-left={textContextMenu?.submenuLeft}>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 1 }))} onclick={() => ctxSetHeading(1)}>Heading 1</button>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</button>
|
||||
<button class:active={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button>
|
||||
@@ -5875,7 +6153,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="table-ctx-overlay" onclick={closeTableContextMenu}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px; max-height: calc(100vh - {tableContextMenu.y}px - 8px); overflow-y: auto;" onclick={(e) => e.stopPropagation()}>
|
||||
<button onclick={tblAddRowBefore}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path d="M12 3v3"/><polyline points="9 4.5 12 2 15 4.5"/></svg>
|
||||
Add Row Above
|
||||
@@ -5932,6 +6210,13 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="table-ctx-sep"></div>
|
||||
{#if tableContextMenu.hasStyling}
|
||||
<button onclick={resetTableToMarkdown} title="Strip cell colors and merged cells, output as plain markdown">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3-6.7"/><polyline points="21 4 21 10 15 10"/></svg>
|
||||
Reset to Markdown
|
||||
</button>
|
||||
<div class="table-ctx-sep"></div>
|
||||
{/if}
|
||||
<button class="danger" onclick={tblDeleteTable}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="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 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||
Delete Table
|
||||
@@ -6146,18 +6431,27 @@
|
||||
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
|
||||
<input class="code-lang-search" type="text" placeholder="Search..." bind:this={codeLangInput} bind:value={codeLangSearch} onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && codeLangFiltered.length > 0) { e.preventDefault(); e.stopPropagation(); selectCodeLang(codeLangFiltered[0]); }
|
||||
if (e.key === 'Escape') closeCodeLangDropdown();
|
||||
}} />
|
||||
{#if !codeLangSearch}
|
||||
<button
|
||||
class="code-lang-option"
|
||||
class:active={codeLangDropdown.current === ''}
|
||||
onclick={() => selectCodeLang('')}
|
||||
>auto</button>
|
||||
{#each codeLanguages as lang}
|
||||
{/if}
|
||||
{#each codeLangFiltered as lang}
|
||||
<button
|
||||
class="code-lang-option"
|
||||
class:active={codeLangDropdown.current === lang}
|
||||
onclick={() => selectCodeLang(lang)}
|
||||
>{lang}</button>
|
||||
{/each}
|
||||
{#if codeLangFiltered.length === 0}
|
||||
<div class="code-lang-empty">No match</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7178,7 +7472,8 @@
|
||||
resize: none;
|
||||
outline: none;
|
||||
padding: 0 0 var(--editor-scroll-past-end, 65vh);
|
||||
margin: 0;
|
||||
margin: 0 auto;
|
||||
max-width: var(--editor-content-width, none);
|
||||
user-select: text;
|
||||
/* Wrap long lines instead of horizontal-scrolling (matches mobile). (issue #100) */
|
||||
white-space: pre-wrap;
|
||||
@@ -7192,6 +7487,9 @@
|
||||
Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
/* The line-number gutter is pinned to the left edge, so don't center/cap here. (#137) */
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.line-numbers-clip {
|
||||
@@ -7224,6 +7522,12 @@
|
||||
.tiptap-wrapper {
|
||||
height: 100%;
|
||||
user-select: text;
|
||||
/* Optional reading-width cap (Settings > Styling > Note Width). Default `none` = full
|
||||
width; when set, the text column is capped and centered. Scrollbar stays at the
|
||||
panel edge because only the inner content is constrained, not .editor-body. (#137) */
|
||||
max-width: var(--editor-content-width, none);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap) {
|
||||
@@ -7311,15 +7615,15 @@
|
||||
|
||||
:global(.tiptap-wrapper .tiptap .code-copy-btn) {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 76px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0.35;
|
||||
opacity: 0.4;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -7349,8 +7653,8 @@
|
||||
:global(.tiptap-wrapper .tiptap pre)::after {
|
||||
content: attr(data-language);
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
top: 5px;
|
||||
right: 34px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
@@ -7358,9 +7662,10 @@
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap pre[data-language=""])::after {
|
||||
@@ -7368,7 +7673,7 @@
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap pre:hover)::after {
|
||||
opacity: 0.7;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap .secret-block) {
|
||||
@@ -7905,12 +8210,36 @@
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
max-height: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
min-width: 130px;
|
||||
min-width: 150px;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
.code-lang-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 8px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.code-lang-search:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.code-lang-empty {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-lang-dropdown::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
@@ -8154,6 +8483,14 @@
|
||||
vertical-align: 15%;
|
||||
}
|
||||
|
||||
:global(html.no-link-arrows .tiptap-wrapper .tiptap a::after) {
|
||||
content: none;
|
||||
}
|
||||
|
||||
:global(html.no-link-arrows .tiptap-wrapper .tiptap a[href$=".md"]::after) {
|
||||
content: none;
|
||||
}
|
||||
|
||||
:global(.tiptap-wrapper .tiptap a[href$=".md"]::after) {
|
||||
content: '⤴';
|
||||
}
|
||||
@@ -8608,8 +8945,6 @@
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
min-width: 200px;
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow-y: auto;
|
||||
z-index: 1501;
|
||||
}
|
||||
|
||||
@@ -8687,6 +9022,13 @@
|
||||
z-index: 1502;
|
||||
}
|
||||
|
||||
.text-ctx-submenu.flip-left {
|
||||
left: auto;
|
||||
right: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.text-ctx-submenu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,10 +5,85 @@
|
||||
import { openUrl } from '$lib/api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
import {
|
||||
ACTIONS,
|
||||
keybindings,
|
||||
setBinding,
|
||||
resetBinding,
|
||||
bindingFromEvent,
|
||||
bindingToKeys,
|
||||
bindingsEqual,
|
||||
type ActionDef,
|
||||
} from '$lib/keybindings';
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
// Customizable shortcuts, grouped for display.
|
||||
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
||||
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
|
||||
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
|
||||
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
|
||||
];
|
||||
|
||||
// id of the action currently listening for a new key combo, or null.
|
||||
let capturingId = $state<string | null>(null);
|
||||
// id of an action whose row should briefly flash a "already in use" warning.
|
||||
let conflictId = $state<string | null>(null);
|
||||
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function startCapture(id: string) {
|
||||
conflictId = null;
|
||||
capturingId = id;
|
||||
}
|
||||
|
||||
function stopCapture() {
|
||||
capturingId = null;
|
||||
}
|
||||
|
||||
function flashConflict(id: string) {
|
||||
conflictId = id;
|
||||
if (conflictTimer) clearTimeout(conflictTimer);
|
||||
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
|
||||
}
|
||||
|
||||
function captureKeydown(e: KeyboardEvent) {
|
||||
if (!capturingId) return;
|
||||
// Capture phase + stopImmediatePropagation keeps the combo from also
|
||||
// triggering the app-level shortcut handler on window.
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (e.key === 'Escape') { stopCapture(); return; }
|
||||
|
||||
const binding = bindingFromEvent(e);
|
||||
if (!binding) return; // modifier-only press; keep listening
|
||||
|
||||
// Reject combos already bound to a different action.
|
||||
const map = $keybindings;
|
||||
const target = capturingId;
|
||||
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
|
||||
if (clash) {
|
||||
flashConflict(target);
|
||||
stopCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
setBinding(target, binding);
|
||||
stopCapture();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!capturingId) return;
|
||||
window.addEventListener('keydown', captureKeydown, true);
|
||||
return () => window.removeEventListener('keydown', captureKeydown, true);
|
||||
});
|
||||
|
||||
function resetKey(e: MouseEvent, id: string) {
|
||||
e.preventDefault();
|
||||
resetBinding(id);
|
||||
if (capturingId === id) stopCapture();
|
||||
}
|
||||
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
let appVersion = $state('...');
|
||||
@@ -158,27 +233,39 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="shortcuts-section">
|
||||
<h4 class="shortcuts-group-title">General</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Interface</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
|
||||
{#if !isMobile}
|
||||
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
|
||||
{/if}
|
||||
{#each actionGroups as group}
|
||||
<h4 class="shortcuts-group-title">{group.title}</h4>
|
||||
{#each group.actions as action}
|
||||
<div class="shortcut-row">
|
||||
<span class="shortcut-desc">{action.label}</span>
|
||||
{#if isMobile}
|
||||
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||
{:else}
|
||||
<button
|
||||
class="shortcut-key-btn"
|
||||
class:capturing={capturingId === action.id}
|
||||
class:conflict={conflictId === action.id}
|
||||
title="Click to rebind · right-click to reset"
|
||||
onclick={() => startCapture(action.id)}
|
||||
oncontextmenu={(e) => resetKey(e, action.id)}
|
||||
>
|
||||
{#if capturingId === action.id}
|
||||
<span class="capture-prompt">Press keys…</span>
|
||||
{:else if conflictId === action.id}
|
||||
<span class="conflict-msg">Already in use</span>
|
||||
{:else}
|
||||
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Navigation</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>←</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>→</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Formatting</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
||||
@@ -189,6 +276,7 @@
|
||||
<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>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Editor Commands</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
|
||||
@@ -501,6 +589,48 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.shortcuts-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.shortcut-key-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shortcut-key-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.shortcut-key-btn.capturing {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.shortcut-key-btn.conflict {
|
||||
border-color: var(--danger, #e11d48);
|
||||
}
|
||||
|
||||
.capture-prompt {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conflict-msg {
|
||||
font-size: 12px;
|
||||
color: var(--danger, #e11d48);
|
||||
}
|
||||
|
||||
.shortcut-keys kbd,
|
||||
.shortcut-desc kbd {
|
||||
display: inline-block;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import {
|
||||
notes,
|
||||
sortedNotes,
|
||||
@@ -11,6 +12,8 @@
|
||||
editorDirty,
|
||||
quickAccessPaths,
|
||||
appConfig,
|
||||
notelistCollapsed,
|
||||
noteOrder,
|
||||
notebooks,
|
||||
tags,
|
||||
mobileView
|
||||
@@ -143,6 +146,7 @@
|
||||
let sortMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let editingNote = $state<string | null>(null);
|
||||
let editValue = $state('');
|
||||
let renameInput: HTMLInputElement | null = $state(null);
|
||||
let movePickerNote = $state<NoteEntry | null>(null);
|
||||
let tagEditNote = $state<NoteEntry | null>(null);
|
||||
let tagEditTags = $state<string[]>([]);
|
||||
@@ -203,6 +207,11 @@
|
||||
let qaDragFrom = $state<number | null>(null);
|
||||
let qaDragOver = $state<number | null>(null);
|
||||
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||
let noteDragFrom = $state<number | null>(null);
|
||||
let noteDragOver = $state<number | null>(null);
|
||||
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
||||
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
|
||||
|
||||
// Virtual scroll
|
||||
let listContainer = $state<HTMLDivElement>(null!);
|
||||
@@ -412,6 +421,7 @@
|
||||
}
|
||||
try {
|
||||
const entry = await createNote(nbRelative, 'Untitled');
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
await refresh();
|
||||
await selectNote(entry);
|
||||
@@ -429,6 +439,7 @@
|
||||
try {
|
||||
const newTitle = editValue.trim();
|
||||
const newPath = await renameNote(note.path, newTitle);
|
||||
renameManualNoteOrderPath(note.path, newPath);
|
||||
noteCache.clear();
|
||||
editingNote = null;
|
||||
// Update local store immediately (avoid full vault re-scan)
|
||||
@@ -450,6 +461,7 @@
|
||||
contextMenu = null;
|
||||
try {
|
||||
await deleteNote(note.path);
|
||||
removeManualNoteOrderPath(note.path);
|
||||
noteCache.clear();
|
||||
// Remove from local store immediately (avoid full vault re-scan)
|
||||
$notes = $notes.filter(n => n.path !== note.path);
|
||||
@@ -477,6 +489,7 @@
|
||||
contextMenu = null;
|
||||
try {
|
||||
await permanentDelete(note.path);
|
||||
removeManualNoteOrderPath(note.path);
|
||||
noteCache.clear();
|
||||
$notes = $notes.filter(n => n.path !== note.path);
|
||||
if ($activeNotePath === note.path) {
|
||||
@@ -670,11 +683,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
function persistManualNoteOrder(orderedNotes: NoteEntry[]) {
|
||||
// Merge into (not replace) the order map so reordering notes in one view does
|
||||
// not wipe the saved custom order of other notebooks / tags / the all-notes view.
|
||||
const updates = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index]));
|
||||
$noteOrder = { ...$noteOrder, ...updates };
|
||||
}
|
||||
|
||||
function appendManualNoteOrder(path: string) {
|
||||
const nextIndex = Math.max(-1, ...Object.values($noteOrder)) + 1;
|
||||
$noteOrder = { ...$noteOrder, [path]: nextIndex };
|
||||
}
|
||||
|
||||
function renameManualNoteOrderPath(oldPath: string, newPath: string) {
|
||||
if (!Object.hasOwn($noteOrder, oldPath)) return;
|
||||
const { [oldPath]: index, ...rest } = $noteOrder;
|
||||
$noteOrder = { ...rest, [newPath]: index };
|
||||
}
|
||||
|
||||
function removeManualNoteOrderPath(path: string) {
|
||||
if (!Object.hasOwn($noteOrder, path)) return;
|
||||
const { [path]: _, ...rest } = $noteOrder;
|
||||
$noteOrder = rest;
|
||||
}
|
||||
|
||||
function resetNoteDrag() {
|
||||
noteDragFrom = null;
|
||||
noteDragOver = null;
|
||||
}
|
||||
|
||||
function handleCustomNoteDrop(targetIndex: number) {
|
||||
if (noteDragFrom === null || !canCustomReorder) {
|
||||
resetNoteDrag();
|
||||
return;
|
||||
}
|
||||
let insertAt = noteDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
|
||||
if (noteDragFrom === insertAt || noteDragFrom + 1 === insertAt) {
|
||||
resetNoteDrag();
|
||||
return;
|
||||
}
|
||||
const arr = [...$sortedNotes];
|
||||
const [moved] = arr.splice(noteDragFrom, 1);
|
||||
if (insertAt > noteDragFrom) insertAt--;
|
||||
arr.splice(insertAt, 0, moved);
|
||||
$notes = arr;
|
||||
persistManualNoteOrder(arr);
|
||||
resetNoteDrag();
|
||||
}
|
||||
|
||||
async function handleMoveNote(note: NoteEntry, destPath: string) {
|
||||
contextMenu = null;
|
||||
movePickerNote = null;
|
||||
try {
|
||||
const newPath = await moveNote(note.path, destPath);
|
||||
renameManualNoteOrderPath(note.path, newPath);
|
||||
noteCache.clear();
|
||||
$notes = $notes.filter(n => n.path !== note.path);
|
||||
if ($activeNotePath === note.path) {
|
||||
@@ -701,10 +763,13 @@
|
||||
contextMenu = { x, y, note };
|
||||
}
|
||||
|
||||
function startRename(note: NoteEntry) {
|
||||
async function startRename(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
editingNote = note.path;
|
||||
editValue = note.meta.title;
|
||||
await tick();
|
||||
renameInput?.focus();
|
||||
renameInput?.select();
|
||||
}
|
||||
|
||||
function getNotebookPath(note: NoteEntry): string {
|
||||
@@ -773,6 +838,7 @@
|
||||
async function handleBatchDelete() {
|
||||
const toDelete = new Set(selectedPaths);
|
||||
noteCache.clear();
|
||||
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||
$activeNote = null;
|
||||
@@ -808,6 +874,7 @@
|
||||
async function handleBatchPermanentDelete() {
|
||||
const toDelete = new Set(selectedPaths);
|
||||
noteCache.clear();
|
||||
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||
$activeNote = null;
|
||||
@@ -833,6 +900,9 @@
|
||||
}
|
||||
|
||||
function setSortMode(mode: SortMode) {
|
||||
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
|
||||
persistManualNoteOrder($sortedNotes);
|
||||
}
|
||||
$sortMode = mode;
|
||||
sortMenu = null;
|
||||
}
|
||||
@@ -856,6 +926,16 @@
|
||||
<div class="list-header">
|
||||
<span class="list-title">{viewTitle}</span>
|
||||
<div class="list-actions">
|
||||
{#if !isMobile}
|
||||
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="6" x2="16" y2="6" />
|
||||
<line x1="4" y1="12" x2="16" y2="12" />
|
||||
<line x1="4" y1="18" x2="16" y2="18" />
|
||||
<polyline points="19 8 21 12 19 16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $viewMode !== 'tasks'}
|
||||
{#if isAndroid}
|
||||
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
|
||||
@@ -1055,6 +1135,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="rename-input"
|
||||
bind:this={renameInput}
|
||||
bind:value={editValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(note);
|
||||
@@ -1072,7 +1153,15 @@
|
||||
class:compact={compact}
|
||||
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
|
||||
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
|
||||
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
|
||||
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
|
||||
onclick={(e) => handleNoteClick(e, note)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
startRename(note);
|
||||
}
|
||||
}}
|
||||
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
|
||||
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
|
||||
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
|
||||
@@ -1099,6 +1188,12 @@
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (canCustomReorder) {
|
||||
noteDragFrom = noteIndex;
|
||||
e.dataTransfer!.setData('text/plain', note.path);
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
||||
} else {
|
||||
@@ -1113,18 +1208,28 @@
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||
qaDragOver = noteIndex;
|
||||
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||
noteDragOver = noteIndex;
|
||||
}
|
||||
}}
|
||||
ondragleave={() => {
|
||||
if (qaDragOver === noteIndex) qaDragOver = null;
|
||||
if (noteDragOver === noteIndex) noteDragOver = null;
|
||||
}}
|
||||
ondrop={(e) => {
|
||||
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
|
||||
e.preventDefault();
|
||||
handleQaDrop(noteIndex);
|
||||
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||
e.preventDefault();
|
||||
handleCustomNoteDrop(noteIndex);
|
||||
}
|
||||
}}
|
||||
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
|
||||
ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
|
||||
>
|
||||
{#if compact}
|
||||
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
|
||||
@@ -1403,6 +1508,18 @@
|
||||
Title
|
||||
{#if $sortMode === 'title'}<span class="sort-check">✓</span>{/if}
|
||||
</button>
|
||||
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="4" cy="6" r="1" />
|
||||
<circle cx="4" cy="12" r="1" />
|
||||
<circle cx="4" cy="18" r="1" />
|
||||
</svg>
|
||||
Custom
|
||||
{#if $sortMode === 'custom'}<span class="sort-check">✓</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1925,11 +2042,13 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.note-item.qa-drag-above {
|
||||
.note-item.qa-drag-above,
|
||||
.note-item.note-drag-above {
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.note-item.qa-drag-below {
|
||||
.note-item.qa-drag-below,
|
||||
.note-item.note-drag-below {
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
theme
|
||||
} from '$lib/stores/app';
|
||||
import { readNote } from '$lib/api';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
import type { FileEvent } from '$lib/types';
|
||||
|
||||
let { notePath }: { notePath: string } = $props();
|
||||
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
let editor = $state<Editor>(null!);
|
||||
@@ -35,8 +38,7 @@
|
||||
|
||||
$effect(() => {
|
||||
const t = $theme || 'system';
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
@@ -72,13 +74,11 @@
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && !e.shiftKey && e.code === 'KeyS') {
|
||||
const action = matchAction(e, $keybindings);
|
||||
if (action === 'save') {
|
||||
e.preventDefault();
|
||||
editor?.forceSave();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && e.code === 'KeyM') {
|
||||
} else if (action === 'toggle-source') {
|
||||
e.preventDefault();
|
||||
$sourceMode = !$sourceMode;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall } from '$lib/stores/app';
|
||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
|
||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
@@ -9,6 +10,7 @@
|
||||
import type { ImportResult, BackupEntry } from '$lib/types';
|
||||
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
|
||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
|
||||
let activeTab = $state<Tab>('styling');
|
||||
@@ -384,7 +386,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson', 'material-dark', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black'];
|
||||
let isThemeDark = $derived(
|
||||
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
);
|
||||
@@ -422,6 +423,7 @@
|
||||
{ id: 'horizon', label: 'Horizon', bg: '#1c1e26', sidebar: '#16181f', accent: '#e9547f' },
|
||||
{ id: 'cyberpunk', label: 'Cyberpunk', bg: '#0a0e17', sidebar: '#060911', accent: '#ff0080' },
|
||||
{ id: 'black', label: 'Black', bg: '#000000', sidebar: '#0a0a0a', accent: '#ffffff' },
|
||||
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
|
||||
];
|
||||
|
||||
const accentPresets = [
|
||||
@@ -476,12 +478,20 @@
|
||||
{ label: '200%', value: 2 },
|
||||
];
|
||||
|
||||
const contentWidthPresets: { label: string; value: number | null }[] = [
|
||||
{ label: 'Narrow', value: 600 },
|
||||
{ label: 'Medium', value: 760 },
|
||||
{ label: 'Wide', value: 960 },
|
||||
{ label: 'Full', value: null },
|
||||
];
|
||||
|
||||
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
||||
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
||||
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
||||
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
||||
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
||||
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
||||
let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null);
|
||||
let themeDropdownOpen = $state(false);
|
||||
let accentDropdownOpen = $state(false);
|
||||
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
|
||||
@@ -507,11 +517,19 @@
|
||||
let titleMode = $state($appConfig?.title_mode ?? 'input');
|
||||
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
|
||||
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 showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
|
||||
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
||||
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
|
||||
|
||||
// Sidebar item visibility
|
||||
let showAllNotes = $state($appConfig?.show_all_notes ?? true);
|
||||
let showQuickAccess = $state($appConfig?.show_quick_access ?? true);
|
||||
let showTasks = $state($appConfig?.show_tasks ?? true);
|
||||
let showDailyNotes = $state($appConfig?.show_daily_notes ?? true);
|
||||
let showTrash = $state($appConfig?.show_trash ?? true);
|
||||
|
||||
const pdfHeightPresets = [
|
||||
{ label: 'Small', value: 300 },
|
||||
{ label: 'Medium', value: 450 },
|
||||
@@ -564,15 +582,32 @@
|
||||
$appConfig.title_mode = titleMode;
|
||||
$appConfig.hide_title_in_body = hideTitleInBody;
|
||||
$appConfig.show_line_numbers = showLineNumbers;
|
||||
$appConfig.show_link_arrows = showLinkArrows;
|
||||
$appConfig.default_view_mode = defaultViewMode;
|
||||
$appConfig.show_tray_icon = showTrayIcon;
|
||||
$appConfig.close_to_tray = closeToTray;
|
||||
$appConfig.enable_wiki_links = enableWikiLinks;
|
||||
$appConfig.show_all_notes = showAllNotes;
|
||||
$appConfig.show_quick_access = showQuickAccess;
|
||||
$appConfig.show_tasks = showTasks;
|
||||
$appConfig.show_daily_notes = showDailyNotes;
|
||||
$appConfig.show_trash = showTrash;
|
||||
}
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession)
|
||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||
.catch((e) => console.error('Failed to save general settings:', e));
|
||||
}
|
||||
|
||||
function applyLinkArrows() {
|
||||
document.documentElement.classList.toggle('no-link-arrows', !showLinkArrows);
|
||||
}
|
||||
|
||||
function selectTheme(preset: typeof themePresets[0]) {
|
||||
$theme = preset.id;
|
||||
setTheme(preset.id);
|
||||
themeDropdownOpen = false;
|
||||
selectCustomAccent(preset.accent);
|
||||
}
|
||||
|
||||
function selectAccent(preset: typeof accentPresets[0]) {
|
||||
activeAccent = preset.name;
|
||||
applyAccent(preset);
|
||||
@@ -667,6 +702,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function selectContentWidth(value: number | null) {
|
||||
activeContentWidth = value;
|
||||
applyContentWidth(value);
|
||||
if ($appConfig) $appConfig.content_width = value;
|
||||
setContentWidth(value).catch((e) => console.error('Failed to save content width:', e));
|
||||
}
|
||||
|
||||
function applyContentWidth(value: number | null) {
|
||||
document.documentElement.style.setProperty('--editor-content-width', value ? `${value}px` : 'none');
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement, callback: () => void) {
|
||||
function handle(e: MouseEvent) {
|
||||
if (!node.contains(e.target as Node)) callback();
|
||||
@@ -718,6 +764,11 @@
|
||||
activeLineHeight = savedLineHeight;
|
||||
applyLineHeight(savedLineHeight);
|
||||
}
|
||||
const savedContentWidth = $appConfig?.content_width;
|
||||
if (savedContentWidth) {
|
||||
activeContentWidth = savedContentWidth;
|
||||
applyContentWidth(savedContentWidth);
|
||||
}
|
||||
// Sync general settings
|
||||
if ($appConfig) {
|
||||
compactNotes = $appConfig.compact_notes ?? false;
|
||||
@@ -731,6 +782,11 @@
|
||||
pdfPreview = $appConfig.pdf_preview ?? false;
|
||||
pdfHeight = $appConfig.pdf_height ?? 600;
|
||||
titleMode = $appConfig.title_mode ?? 'input';
|
||||
showAllNotes = $appConfig.show_all_notes ?? true;
|
||||
showQuickAccess = $appConfig.show_quick_access ?? true;
|
||||
showTasks = $appConfig.show_tasks ?? true;
|
||||
showDailyNotes = $appConfig.show_daily_notes ?? true;
|
||||
showTrash = $appConfig.show_trash ?? true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -854,6 +910,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !isMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Sidebar</h3>
|
||||
<p class="setting-desc" style="margin: 0 0 10px;">Hide navigation items you don't use, leaving search and the notebook tree. Any item you hide stays reachable from the command palette ({modKey}+P).</p>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">All Notes</span></span>
|
||||
<button class="toggle-switch" class:on={showAllNotes} onclick={() => { showAllNotes = !showAllNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Quick Access</span></span>
|
||||
<button class="toggle-switch" class:on={showQuickAccess} onclick={() => { showQuickAccess = !showQuickAccess; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Tasks</span></span>
|
||||
<button class="toggle-switch" class:on={showTasks} onclick={() => { showTasks = !showTasks; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Daily Notes</span></span>
|
||||
<button class="toggle-switch" class:on={showDailyNotes} onclick={() => { showDailyNotes = !showDailyNotes; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label"><span class="setting-name">Trash</span></span>
|
||||
<button class="toggle-switch" class:on={showTrash} onclick={() => { showTrash = !showTrash; saveGeneralSettings(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Time Format</h3>
|
||||
<div class="setting-options">
|
||||
@@ -956,6 +1049,15 @@
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
<label class="setting-toggle" style="margin-top: 12px;">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Show link arrows</span>
|
||||
<span class="setting-desc">Display the arrow icon after external links</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={showLinkArrows} onclick={() => { showLinkArrows = !showLinkArrows; saveGeneralSettings(); applyLinkArrows(); }}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
{#if !isMobile}
|
||||
<label class="setting-toggle" style="margin-top: 12px;">
|
||||
<span class="setting-label">
|
||||
@@ -1061,7 +1163,7 @@
|
||||
<button
|
||||
class="dropdown-item"
|
||||
class:active={$theme === preset.id}
|
||||
onclick={() => { $theme = preset.id; setTheme(preset.id); themeDropdownOpen = false; }}
|
||||
onclick={() => selectTheme(preset)}
|
||||
>
|
||||
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||
<span class="preview-sidebar"></span>
|
||||
@@ -1162,6 +1264,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isMobile}
|
||||
<div class="settings-section">
|
||||
<h3>Note Width</h3>
|
||||
<div class="font-size-options">
|
||||
{#each contentWidthPresets as preset}
|
||||
<button
|
||||
class="font-size-btn"
|
||||
class:active={activeContentWidth === preset.value}
|
||||
onclick={() => selectContentWidth(preset.value)}
|
||||
>
|
||||
<span class="font-size-label">{preset.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="setting-hint">Caps the width of the note text for easier reading on wide screens; the column stays centered. Most noticeable in Focus Mode. "Full" uses the entire width.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Line Height</h3>
|
||||
<div class="font-size-options">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import {
|
||||
notebooks,
|
||||
notes,
|
||||
@@ -20,7 +21,7 @@
|
||||
notebookSortMode,
|
||||
notebookOrder
|
||||
} from '$lib/stores/app';
|
||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
|
||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { readFile } from '@tauri-apps/plugin-fs';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
@@ -33,10 +34,22 @@
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
|
||||
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
|
||||
const anyNavItem = $derived(
|
||||
$appConfig?.show_all_notes !== false ||
|
||||
$appConfig?.show_quick_access !== false ||
|
||||
$appConfig?.show_tasks !== false ||
|
||||
$appConfig?.show_daily_notes !== false ||
|
||||
$appConfig?.show_trash !== false
|
||||
);
|
||||
|
||||
let editingNotebook = $state<string | null>(null);
|
||||
let editValue = $state('');
|
||||
let renameInput: HTMLInputElement | null = $state(null);
|
||||
let newNotebookName = $state('');
|
||||
let showNewNotebook = $state(false);
|
||||
let newNotebookInput: HTMLInputElement | null = $state(null);
|
||||
let dropTargetPath = $state<string | null>(null);
|
||||
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
|
||||
let draggedNotebookPath = $state<string | null>(null);
|
||||
@@ -59,19 +72,25 @@
|
||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let tagsCollapsed = $state(true);
|
||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||
function toggleCollapse(path: string, e: MouseEvent) {
|
||||
function expandNotebook(nb: NotebookEntry) {
|
||||
if ($collapsedNotebooks.includes(nb.path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
||||
e.stopPropagation();
|
||||
if ($collapsedNotebooks.includes(path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== path);
|
||||
if ($collapsedNotebooks.includes(nb.path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||
} else {
|
||||
$collapsedNotebooks = [...$collapsedNotebooks, path];
|
||||
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
|
||||
}
|
||||
}
|
||||
// Collapse-all / expand-all: toggles every notebook that has children.
|
||||
function collectParentPaths(tree: NotebookEntry[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const nb of tree) {
|
||||
if (nb.children && nb.children.length > 0) {
|
||||
if (nb.children.length > 0) {
|
||||
out.push(nb.path);
|
||||
out.push(...collectParentPaths(nb.children));
|
||||
}
|
||||
@@ -133,12 +152,23 @@
|
||||
onViewChanged();
|
||||
}
|
||||
|
||||
function selectNotebook(nb: NotebookEntry) {
|
||||
async function selectNotebook(nb: NotebookEntry) {
|
||||
const hasVisibleChildren = nb.children.length > 0;
|
||||
const wasCollapsed = $collapsedNotebooks.includes(nb.path);
|
||||
// Deselect if clicking the already-active notebook
|
||||
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
||||
if (hasVisibleChildren) {
|
||||
if (wasCollapsed) {
|
||||
await expandNotebook(nb);
|
||||
} else {
|
||||
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectAllNotes();
|
||||
return;
|
||||
}
|
||||
if (hasVisibleChildren) await expandNotebook(nb);
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = nb;
|
||||
$activeTag = null;
|
||||
@@ -182,11 +212,52 @@
|
||||
|
||||
let newNotebookParent = $state<NotebookEntry | null>(null);
|
||||
|
||||
function getNotebookNameSegments(name: string): string[] | null {
|
||||
const segments = name.split('/').map((segment) => segment.trim());
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) {
|
||||
return null;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function focusNewNotebookInput() {
|
||||
await tick();
|
||||
newNotebookInput?.focus();
|
||||
}
|
||||
|
||||
async function toggleNewNotebookInput() {
|
||||
if (showNewNotebook) {
|
||||
showNewNotebook = false;
|
||||
newNotebookName = '';
|
||||
newNotebookParent = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
|
||||
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
||||
showNewNotebook = true;
|
||||
await focusNewNotebookInput();
|
||||
}
|
||||
|
||||
async function startNewNotebookFromSidebar(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('button, input, .context-menu, .delete-confirm-overlay')) return;
|
||||
|
||||
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
||||
newNotebookName = '';
|
||||
showNewNotebook = true;
|
||||
await focusNewNotebookInput();
|
||||
}
|
||||
|
||||
async function handleCreateNotebook() {
|
||||
if (!newNotebookName.trim()) return;
|
||||
try {
|
||||
const trimmedName = newNotebookName.trim();
|
||||
const parentRel = newNotebookParent?.relative_path ?? null;
|
||||
await createNotebook(parentRel, newNotebookName.trim());
|
||||
const name = parentRel ? trimmedName : getNotebookNameSegments(trimmedName)?.join('/');
|
||||
if (!name) return;
|
||||
|
||||
await createNotebook(parentRel, name);
|
||||
newNotebookName = '';
|
||||
showNewNotebook = false;
|
||||
newNotebookParent = null;
|
||||
@@ -196,7 +267,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startNewSubNotebook(nb: NotebookEntry) {
|
||||
function handleNewNotebookKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateNotebook();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
showNewNotebook = false;
|
||||
newNotebookName = '';
|
||||
newNotebookParent = null;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' && newNotebookParent && newNotebookName.length === 0) {
|
||||
e.preventDefault();
|
||||
newNotebookParent = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewSubNotebook(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
newNotebookParent = nb;
|
||||
newNotebookName = '';
|
||||
@@ -205,6 +293,7 @@
|
||||
if ($collapsedNotebooks.includes(nb.path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||
}
|
||||
await focusNewNotebookInput();
|
||||
}
|
||||
|
||||
async function handleRename(nb: NotebookEntry) {
|
||||
@@ -394,10 +483,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(nb: NotebookEntry) {
|
||||
async function startRename(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
editingNotebook = nb.path;
|
||||
editValue = nb.name;
|
||||
await tick();
|
||||
renameInput?.focus();
|
||||
renameInput?.select();
|
||||
}
|
||||
|
||||
async function handleSetIcon(nb: NotebookEntry) {
|
||||
@@ -486,7 +578,7 @@
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}>
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem} ondblclick={startNewNotebookFromSidebar}>
|
||||
{#if !isMobile}
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||
@@ -498,19 +590,19 @@
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
{#if !$sidebarCollapsed}
|
||||
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$sidebarCollapsed}
|
||||
{#if anyNavItem}
|
||||
<nav class="sidebar-nav">
|
||||
{#if $appConfig?.show_all_notes !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'all'}
|
||||
@@ -522,7 +614,9 @@
|
||||
</svg>
|
||||
<span>All Notes</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_quick_access !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'quickaccess'}
|
||||
@@ -533,7 +627,9 @@
|
||||
</svg>
|
||||
<span>Quick Access</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_tasks !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'tasks'}
|
||||
@@ -545,7 +641,9 @@
|
||||
</svg>
|
||||
<span>Tasks</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_daily_notes !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'daily'}
|
||||
@@ -559,7 +657,9 @@
|
||||
</svg>
|
||||
<span>Daily Notes</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_trash !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'trash'}
|
||||
@@ -571,7 +671,9 @@
|
||||
</svg>
|
||||
<span>Trash</span>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -606,15 +708,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="icon-btn-sm" onclick={() => {
|
||||
if (showNewNotebook) {
|
||||
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
|
||||
} else {
|
||||
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
|
||||
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
||||
showNewNotebook = true;
|
||||
}
|
||||
}} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
|
||||
<button class="icon-btn-sm" onclick={toggleNewNotebookInput} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -630,13 +724,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={newNotebookInput}
|
||||
type="text"
|
||||
bind:value={newNotebookName}
|
||||
placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateNotebook();
|
||||
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; }
|
||||
}}
|
||||
onkeydown={handleNewNotebookKeydown}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -708,6 +800,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="icon-btn" onclick={() => ($showInfo = true)} title="Info">
|
||||
@@ -727,7 +820,6 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
{#if contextMenu}
|
||||
@@ -794,6 +886,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="rename-input"
|
||||
bind:this={renameInput}
|
||||
bind:value={editValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(nb);
|
||||
@@ -812,6 +905,12 @@
|
||||
style="padding-left: {8 + depth * 16}px"
|
||||
draggable="true"
|
||||
onclick={() => selectNotebook(nb)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
startRename(nb);
|
||||
}
|
||||
}}
|
||||
oncontextmenu={(e) => onContextMenu(e, nb)}
|
||||
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
|
||||
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
|
||||
@@ -863,7 +962,7 @@
|
||||
>
|
||||
{#if hasChildren}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb.path, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb.path, e); }}>
|
||||
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb, e); }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
@@ -906,6 +1005,29 @@
|
||||
max-width: 44px;
|
||||
}
|
||||
|
||||
/* Collapsed rail: stack the toolbar icons vertically - search under the expand chevron at
|
||||
the top, info + settings pinned to the bottom. */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-footer {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
padding: 8px 4px;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-footer-right {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -949,6 +1071,22 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* With the top nav hidden, the header's border-bottom is the only divider needed -
|
||||
drop the section's border-top so there's no doubled line under the header. */
|
||||
.sidebar.nav-empty .section {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Pin the Notebooks header so its collapse/expand-all button stays reachable while the tree
|
||||
scrolls; the section's border-top draws the divider above it, matching the Tags section. (issue #126) */
|
||||
.section > .section-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tags-section {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
// A single key combination. `mod` means Ctrl on Windows/Linux and Cmd on macOS.
|
||||
// `code` is a KeyboardEvent.code value (layout-independent), e.g. "KeyN",
|
||||
// "Backslash", "ArrowLeft", "F11".
|
||||
export interface Binding {
|
||||
mod?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ActionDef {
|
||||
id: string;
|
||||
label: string;
|
||||
group: "General" | "Interface" | "Navigation";
|
||||
default: Binding;
|
||||
}
|
||||
|
||||
// Every customizable action and its factory-default binding. These mirror the
|
||||
// handlers in AppLayout.handleKeydown and the rows in InfoPanel's Shortcuts tab.
|
||||
export const ACTIONS: ActionDef[] = [
|
||||
{ id: "new-note", label: "New note", group: "General", default: { mod: true, code: "KeyN" } },
|
||||
{ id: "save", label: "Save", group: "General", default: { mod: true, code: "KeyS" } },
|
||||
{ id: "quick-open", label: "Quick open", group: "General", default: { mod: true, code: "KeyP" } },
|
||||
{ id: "find-in-note", label: "Find in note", group: "General", default: { mod: true, code: "KeyF" } },
|
||||
{ id: "search-vault", label: "Search vault", group: "General", default: { mod: true, shift: true, code: "KeyF" } },
|
||||
{ id: "open-new-window", label: "Open note in new window", group: "General", default: { mod: true, shift: true, code: "KeyW" } },
|
||||
|
||||
{ id: "toggle-sidebar", label: "Toggle sidebar", group: "Interface", default: { mod: true, code: "Backslash" } },
|
||||
{ id: "toggle-notelist", label: "Toggle notes list", group: "Interface", default: { mod: true, shift: true, code: "Backslash" } },
|
||||
{ id: "toggle-theme", label: "Toggle dark / light theme", group: "Interface", default: { mod: true, shift: true, code: "KeyN" } },
|
||||
{ id: "toggle-focus", label: "Toggle focus mode", group: "Interface", default: { mod: true, shift: true, code: "KeyE" } },
|
||||
{ id: "toggle-readonly", label: "Toggle read-only", group: "Interface", default: { mod: true, shift: true, code: "KeyR" } },
|
||||
{ id: "toggle-source", label: "Toggle source / WYSIWYG", group: "Interface", default: { mod: true, shift: true, code: "KeyM" } },
|
||||
{ id: "fullscreen", label: "Toggle fullscreen", group: "Interface", default: { code: "F11" } },
|
||||
|
||||
{ id: "nav-back", label: "Go back", group: "Navigation", default: { alt: true, code: "ArrowLeft" } },
|
||||
{ id: "nav-forward", label: "Go forward", group: "Navigation", default: { alt: true, code: "ArrowRight" } },
|
||||
];
|
||||
|
||||
export const DEFAULT_BINDINGS: Record<string, Binding> = Object.fromEntries(
|
||||
ACTIONS.map((a) => [a.id, a.default]),
|
||||
);
|
||||
|
||||
const STORAGE_KEY = "helix-keybindings";
|
||||
|
||||
function loadBindings(): Record<string, Binding> {
|
||||
const merged: Record<string, Binding> = { ...DEFAULT_BINDINGS };
|
||||
try {
|
||||
const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const stored = JSON.parse(raw) as Record<string, Binding>;
|
||||
for (const id of Object.keys(merged)) {
|
||||
if (stored[id]) merged[id] = stored[id];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt storage - fall back to defaults.
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export const keybindings = writable<Record<string, Binding>>(loadBindings());
|
||||
|
||||
keybindings.subscribe((map) => {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||
}
|
||||
} catch {
|
||||
// Ignore quota / unavailable storage.
|
||||
}
|
||||
});
|
||||
|
||||
export function setBinding(id: string, binding: Binding) {
|
||||
keybindings.update((m) => ({ ...m, [id]: binding }));
|
||||
}
|
||||
|
||||
export function resetBinding(id: string) {
|
||||
keybindings.update((m) => ({ ...m, [id]: DEFAULT_BINDINGS[id] }));
|
||||
}
|
||||
|
||||
// True when the keyboard event matches the given binding exactly. Modifier
|
||||
// state must match precisely so e.g. Mod+Shift+F never also triggers Mod+F.
|
||||
export function matchBinding(e: KeyboardEvent, b: Binding | undefined): boolean {
|
||||
if (!b) return false;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
return (
|
||||
!!b.mod === mod &&
|
||||
!!b.shift === e.shiftKey &&
|
||||
!!b.alt === e.altKey &&
|
||||
e.code === b.code
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the id of the first action whose binding matches the event, or null.
|
||||
export function matchAction(e: KeyboardEvent, map: Record<string, Binding>): string | null {
|
||||
for (const id of Object.keys(map)) {
|
||||
if (matchBinding(e, map[id])) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build a Binding from a captured keydown event. Returns null for events that
|
||||
// are only modifier keys (no real key pressed yet).
|
||||
const MODIFIER_CODES = new Set([
|
||||
"ControlLeft", "ControlRight", "MetaLeft", "MetaRight",
|
||||
"ShiftLeft", "ShiftRight", "AltLeft", "AltRight",
|
||||
]);
|
||||
|
||||
export function bindingFromEvent(e: KeyboardEvent): Binding | null {
|
||||
if (MODIFIER_CODES.has(e.code) || !e.code) return null;
|
||||
return {
|
||||
mod: e.ctrlKey || e.metaKey,
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
code: e.code,
|
||||
};
|
||||
}
|
||||
|
||||
export function bindingsEqual(a: Binding, b: Binding): boolean {
|
||||
return !!a.mod === !!b.mod && !!a.shift === !!b.shift && !!a.alt === !!b.alt && a.code === b.code;
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && navigator.platform.startsWith("Mac");
|
||||
|
||||
const CODE_LABELS: Record<string, string> = {
|
||||
Backslash: "\\",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Semicolon: ";",
|
||||
Quote: "'",
|
||||
Backquote: "`",
|
||||
Minus: "-",
|
||||
Equal: "=",
|
||||
Space: "Space",
|
||||
Enter: "Enter",
|
||||
Tab: "Tab",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
};
|
||||
|
||||
function codeToLabel(code: string): string {
|
||||
if (code.startsWith("Key")) return code.slice(3);
|
||||
if (code.startsWith("Digit")) return code.slice(5);
|
||||
if (code.startsWith("Numpad")) return code.slice(6);
|
||||
return CODE_LABELS[code] ?? code;
|
||||
}
|
||||
|
||||
// Ordered list of key labels for rendering, e.g. ["Ctrl", "Shift", "N"].
|
||||
export function bindingToKeys(b: Binding): string[] {
|
||||
const keys: string[] = [];
|
||||
if (b.mod) keys.push(isMac ? "⌘" : "Ctrl");
|
||||
if (b.alt) keys.push(isMac ? "⌥" : "Alt");
|
||||
if (b.shift) keys.push(isMac ? "⇧" : "Shift");
|
||||
keys.push(codeToLabel(b.code));
|
||||
return keys;
|
||||
}
|
||||
@@ -5,3 +5,37 @@ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
export const isAndroid = /android/i.test(ua);
|
||||
export const isIOS = /iphone|ipad|ipod/i.test(ua);
|
||||
export const isMobile = isAndroid || isIOS;
|
||||
|
||||
// Themes that use the dark color scheme. Used by applyTheme() to toggle the
|
||||
// `dark` class on the root element. Keep this in sync when adding new themes.
|
||||
export const darkThemes = [
|
||||
'dark',
|
||||
'solarized-dark',
|
||||
'catppuccin',
|
||||
'nord',
|
||||
'tokyo-night',
|
||||
'github-dark',
|
||||
'dracula',
|
||||
'blueberry',
|
||||
'forest-green',
|
||||
'gruvbox',
|
||||
'midnight-tide',
|
||||
'cherry-blossom',
|
||||
'synthwave',
|
||||
'ember',
|
||||
'moonlit',
|
||||
'dark-coffee',
|
||||
'crimson',
|
||||
'material-dark',
|
||||
'monokai',
|
||||
'rose-pine',
|
||||
'everforest',
|
||||
'horizon',
|
||||
'cyberpunk',
|
||||
'black',
|
||||
'one-dark',
|
||||
];
|
||||
|
||||
export function isDarkTheme(theme: string): boolean {
|
||||
return darkThemes.includes(theme) || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
|
||||
+24
-2
@@ -21,6 +21,7 @@ export const tasksHideCompleted = writable<boolean>(true);
|
||||
export const tasksOnlyFlagged = writable<boolean>(false);
|
||||
export const tasksSort = writable<"due" | "priority" | "note">("due");
|
||||
export const sidebarCollapsed = writable(false);
|
||||
export const notelistCollapsed = writable(false);
|
||||
export const sidebarWidth = writable(220);
|
||||
export const notelistWidth = writable(280);
|
||||
export const searchQuery = writable("");
|
||||
@@ -39,6 +40,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">(
|
||||
);
|
||||
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
|
||||
export const notebookOrder = writable<Record<string, number>>({});
|
||||
export const noteOrder = writable<Record<string, number>>({});
|
||||
|
||||
// Data
|
||||
export const notebooks = writable<NotebookEntry[]>([]);
|
||||
@@ -176,16 +178,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length
|
||||
|
||||
// Derived
|
||||
export const sortedNotes = derived(
|
||||
[notes, sortMode, viewMode],
|
||||
([$notes, $sortMode, $viewMode]) => {
|
||||
[notes, sortMode, viewMode, noteOrder],
|
||||
([$notes, $sortMode, $viewMode, $noteOrder]) => {
|
||||
// Quick Access preserves stored order
|
||||
if ($viewMode === "quickaccess") return $notes;
|
||||
const customSortable =
|
||||
$viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag";
|
||||
|
||||
const pinned = $notes.filter((n) => n.meta.pinned);
|
||||
const unpinned = $notes.filter((n) => !n.meta.pinned);
|
||||
|
||||
const sortFn = (a: NoteEntry, b: NoteEntry) => {
|
||||
switch ($sortMode) {
|
||||
case "custom": {
|
||||
if (!customSortable) {
|
||||
return (
|
||||
new Date(b.meta.modified).getTime() -
|
||||
new Date(a.meta.modified).getTime()
|
||||
);
|
||||
}
|
||||
const oa = $noteOrder[a.path] ?? Number.MAX_SAFE_INTEGER;
|
||||
const ob = $noteOrder[b.path] ?? Number.MAX_SAFE_INTEGER;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
}
|
||||
case "title":
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
case "created":
|
||||
@@ -212,27 +228,33 @@ export const vaultState = derived(
|
||||
sidebarWidth,
|
||||
notelistWidth,
|
||||
sidebarCollapsed,
|
||||
notelistCollapsed,
|
||||
collapsedNotebooks,
|
||||
notebookSortMode,
|
||||
notebookOrder,
|
||||
noteOrder,
|
||||
],
|
||||
([
|
||||
$activeNotePath,
|
||||
$sidebarWidth,
|
||||
$notelistWidth,
|
||||
$sidebarCollapsed,
|
||||
$notelistCollapsed,
|
||||
$collapsedNotebooks,
|
||||
$notebookSortMode,
|
||||
$notebookOrder,
|
||||
$noteOrder,
|
||||
]) => {
|
||||
return {
|
||||
last_open_note: $activeNotePath,
|
||||
sidebar_width: $sidebarWidth,
|
||||
notelist_width: $notelistWidth,
|
||||
sidebar_collapsed: $sidebarCollapsed,
|
||||
notelist_collapsed: $notelistCollapsed,
|
||||
collapsed_notebooks: $collapsedNotebooks,
|
||||
notebook_sort_mode: $notebookSortMode,
|
||||
notebook_order: $notebookOrder,
|
||||
note_order: $noteOrder,
|
||||
} satisfies VaultState;
|
||||
},
|
||||
);
|
||||
|
||||
+10
-1
@@ -55,6 +55,7 @@ export interface AppConfig {
|
||||
font_family: string | null;
|
||||
line_height: number | null;
|
||||
ui_scale: number | null;
|
||||
content_width: number | null;
|
||||
compact_notes: boolean;
|
||||
show_note_dates: boolean;
|
||||
time_format: string;
|
||||
@@ -67,6 +68,12 @@ export interface AppConfig {
|
||||
title_mode: string;
|
||||
hide_title_in_body: boolean;
|
||||
show_line_numbers: boolean;
|
||||
show_link_arrows: boolean;
|
||||
show_all_notes: boolean;
|
||||
show_quick_access: boolean;
|
||||
show_tasks: boolean;
|
||||
show_daily_notes: boolean;
|
||||
show_trash: boolean;
|
||||
backup_enabled: boolean;
|
||||
backup_frequency: string;
|
||||
backup_max_count: number;
|
||||
@@ -103,9 +110,11 @@ export interface VaultState {
|
||||
sidebar_width: number;
|
||||
notelist_width: number;
|
||||
sidebar_collapsed: boolean;
|
||||
notelist_collapsed: boolean;
|
||||
collapsed_notebooks: string[];
|
||||
notebook_sort_mode?: string;
|
||||
notebook_order?: Record<string, number>;
|
||||
note_order?: Record<string, number>;
|
||||
sort_mode?: string;
|
||||
last_view_mode?: string;
|
||||
last_notebook?: string | null;
|
||||
@@ -164,7 +173,7 @@ export interface NoteTitleEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type SortMode = "modified" | "title" | "created";
|
||||
export type SortMode = "modified" | "title" | "created" | "custom";
|
||||
export type ViewMode =
|
||||
| "all"
|
||||
| "notebook"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app';
|
||||
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
|
||||
import { get } from 'svelte/store';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
@@ -15,9 +16,15 @@
|
||||
applyTheme($theme);
|
||||
});
|
||||
|
||||
// Apply link arrow visibility from config
|
||||
$effect(() => {
|
||||
if ($appConfig) {
|
||||
document.documentElement.classList.toggle('no-link-arrows', !$appConfig.show_link_arrows);
|
||||
}
|
||||
});
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson', 'material-dark', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||
import { getAppConfig, openVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||
@@ -120,8 +121,7 @@
|
||||
// Apply theme immediately to prevent flash
|
||||
const themeValue = config.theme || 'system';
|
||||
const root = document.documentElement;
|
||||
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(themeValue)) {
|
||||
@@ -138,6 +138,9 @@
|
||||
if (config.line_height) {
|
||||
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
|
||||
}
|
||||
if (config.content_width) {
|
||||
document.documentElement.style.setProperty('--editor-content-width', `${config.content_width}px`);
|
||||
}
|
||||
if (config.font_family) {
|
||||
const fontStacks: Record<string, string> = {
|
||||
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
|
||||
Reference in New Issue
Block a user