mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-23 23:35:57 +02:00
notebook drag-and-drop, nested list fix, macOS updates, search bar auto-close
This commit is contained in:
@@ -8,9 +8,9 @@ Your notes are stored as standard Markdown files on your local filesystem. No cl
|
|||||||
|
|
||||||
| Platform | Download | Notes |
|
| Platform | Download | Notes |
|
||||||
|----------|----------|-------|
|
|----------|----------|-------|
|
||||||
| Linux (Arch/rolling) | [HelixNotes_1.0.6_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.6/HelixNotes_1.0.6_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
|
| Linux (Arch/rolling) | [HelixNotes_1.0.9_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
|
||||||
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.0.6_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.6/HelixNotes_1.0.6_amd64.deb) | Ubuntu 22.04+ |
|
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.0.9_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_amd64.deb) | Ubuntu 22.04+ |
|
||||||
| Windows | [HelixNotes_1.0.6_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.6/HelixNotes_1.0.6_x64-setup.exe) | Windows 10/11 |
|
| Windows | [HelixNotes_1.0.9_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_x64-setup.exe) | Windows 10/11 |
|
||||||
| macOS | Coming soon | |
|
| macOS | Coming soon | |
|
||||||
|
|
||||||
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||||
@@ -25,6 +25,7 @@ See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.or
|
|||||||
- **Version history** — per-note snapshots with diff view
|
- **Version history** — per-note snapshots with diff view
|
||||||
- **Backups** — automatic zip-based vault backups
|
- **Backups** — automatic zip-based vault backups
|
||||||
- **PDF preview** — inline rendering of embedded PDFs
|
- **PDF preview** — inline rendering of embedded PDFs
|
||||||
|
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
|
||||||
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
|
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
|
||||||
- **Themes** — light/dark mode with customizable accent colors and fonts
|
- **Themes** — light/dark mode with customizable accent colors and fonts
|
||||||
- **Focus mode** — distraction-free writing
|
- **Focus mode** — distraction-free writing
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
Generated
+17
-1
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.0.8"
|
version = "1.0.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -1799,6 +1799,7 @@ dependencies = [
|
|||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -5081,6 +5082,21 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.0.8"
|
version = "1.0.9"
|
||||||
description = "Local-first markdown note-taking app"
|
description = "Local-first markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
@@ -38,3 +38,4 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -102,6 +102,77 @@ pub fn rename_notebook(path: String, new_name: String) -> Result<String, String>
|
|||||||
operations::rename_notebook(&path, &new_name)
|
operations::rename_notebook(&path, &new_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn move_notebook(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
notebook_path: String,
|
||||||
|
dest_parent: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||||
|
|
||||||
|
let old_relative = Path::new(¬ebook_path)
|
||||||
|
.strip_prefix(vault_path.as_str())
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let new_full_path = operations::move_notebook(¬ebook_path, &dest_parent)?;
|
||||||
|
|
||||||
|
let new_relative = Path::new(&new_full_path)
|
||||||
|
.strip_prefix(vault_path.as_str())
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Update Quick Access paths for all notes inside the moved notebook
|
||||||
|
if let Ok(mut qa) = operations::load_quick_access(vault_path) {
|
||||||
|
let old_prefix = format!("{}/", old_relative);
|
||||||
|
let mut changed = false;
|
||||||
|
for path in qa.iter_mut() {
|
||||||
|
if path.starts_with(&old_prefix) {
|
||||||
|
*path = format!("{}/{}", new_relative, &path[old_prefix.len()..]);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
let _ = operations::save_quick_access(vault_path, &qa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update notebook icon mappings
|
||||||
|
if let Ok(icons) = operations::load_notebook_icons(vault_path) {
|
||||||
|
let old_prefix = format!("{}/", old_relative);
|
||||||
|
let mut new_icons = std::collections::HashMap::new();
|
||||||
|
let mut changed = false;
|
||||||
|
for (key, value) in &icons {
|
||||||
|
if *key == old_relative {
|
||||||
|
new_icons.insert(new_relative.clone(), value.clone());
|
||||||
|
changed = true;
|
||||||
|
} else if key.starts_with(&old_prefix) {
|
||||||
|
let new_key = format!("{}/{}", new_relative, &key[old_prefix.len()..]);
|
||||||
|
new_icons.insert(new_key, value.clone());
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
new_icons.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
let icons_path = operations::helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||||
|
if let Ok(data) = serde_json::to_string_pretty(&new_icons) {
|
||||||
|
let _ = std::fs::write(&icons_path, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild search index (paths changed for all contained notes)
|
||||||
|
if let Ok(search_guard) = state.search_index.lock() {
|
||||||
|
if let Some(ref search) = *search_guard {
|
||||||
|
let _ = search.rebuild(vault_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_full_path)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -1385,12 +1456,22 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_install_type() -> String {
|
pub fn get_install_type() -> String {
|
||||||
if cfg!(target_os = "windows") {
|
if cfg!(target_os = "macos") {
|
||||||
|
"macos".to_string()
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
"windows".to_string()
|
"windows".to_string()
|
||||||
} else if std::env::var("APPIMAGE").is_ok() {
|
} else if std::env::var("APPIMAGE").is_ok() {
|
||||||
"appimage".to_string()
|
"appimage".to_string()
|
||||||
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
||||||
"deb".to_string()
|
"deb".to_string()
|
||||||
|
} else if std::path::Path::new("/var/lib/pacman/local").exists()
|
||||||
|
&& std::process::Command::new("pacman")
|
||||||
|
.args(["-Q", "helixnotes"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"aur".to_string()
|
||||||
} else {
|
} else {
|
||||||
"native".to_string()
|
"native".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ pub fn run() {
|
|||||||
let app_state = AppState::new(config);
|
let app_state = AppState::new(config);
|
||||||
|
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}))
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
@@ -54,6 +61,7 @@ pub fn run() {
|
|||||||
commands::create_notebook,
|
commands::create_notebook,
|
||||||
commands::rename_notebook,
|
commands::rename_notebook,
|
||||||
commands::delete_notebook,
|
commands::delete_notebook,
|
||||||
|
commands::move_notebook,
|
||||||
commands::get_notes,
|
commands::get_notes,
|
||||||
commands::read_note,
|
commands::read_note,
|
||||||
commands::save_note,
|
commands::save_note,
|
||||||
|
|||||||
@@ -432,6 +432,38 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
|
|||||||
Ok(dest.to_string_lossy().to_string())
|
Ok(dest.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
|
||||||
|
let src = Path::new(notebook_path);
|
||||||
|
if !src.exists() || !src.is_dir() {
|
||||||
|
return Err("Notebook does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest_parent_path = Path::new(dest_parent);
|
||||||
|
if !dest_parent_path.is_dir() {
|
||||||
|
return Err("Destination does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op if already in that parent
|
||||||
|
if src.parent() == Some(dest_parent_path) {
|
||||||
|
return Err("Notebook is already in that location".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir_name = src.file_name().unwrap_or_default();
|
||||||
|
let dest = dest_parent_path.join(dir_name);
|
||||||
|
|
||||||
|
// Prevent moving into itself or a descendant
|
||||||
|
if dest.starts_with(src) {
|
||||||
|
return Err("Cannot move a notebook into itself or its descendants".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if dest.exists() {
|
||||||
|
return Err("A notebook with that name already exists in the destination".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(src, &dest).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dest.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||||
if !trash_dir.exists() {
|
if !trash_dir.exists() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export async function deleteNotebook(path: string): Promise<void> {
|
|||||||
return invoke("delete_notebook", { path });
|
return invoke("delete_notebook", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function moveNotebook(
|
||||||
|
notebookPath: string,
|
||||||
|
destParent: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return invoke("move_notebook", { notebookPath, destParent });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getNotes(
|
export async function getNotes(
|
||||||
notebookPath: string | null,
|
notebookPath: string | null,
|
||||||
): Promise<NoteEntry[]> {
|
): Promise<NoteEntry[]> {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
|
|
||||||
let anyDropdownOpen = $derived(headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen);
|
let anyDropdownOpen = $derived(headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen);
|
||||||
let editorState = $state(0);
|
let editorState = $state(0);
|
||||||
|
let editorStateRaf = 0; // RAF handle for batching toolbar updates
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
let aiMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
@@ -169,6 +170,8 @@
|
|||||||
.use(markdownItMark)
|
.use(markdownItMark)
|
||||||
.use(markdownItSup)
|
.use(markdownItSup)
|
||||||
.use(markdownItSub);
|
.use(markdownItSub);
|
||||||
|
// Disable indented code blocks — tab-indented text should stay as text, not become code
|
||||||
|
mdit.disable('code');
|
||||||
|
|
||||||
function normalizePath(p: string): string {
|
function normalizePath(p: string): string {
|
||||||
const parts = p.split('/');
|
const parts = p.split('/');
|
||||||
@@ -313,37 +316,60 @@
|
|||||||
|
|
||||||
const CodeBlockLanguageSelect = Extension.create({
|
const CodeBlockLanguageSelect = Extension.create({
|
||||||
name: 'codeBlockLanguageSelect',
|
name: 'codeBlockLanguageSelect',
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [{
|
||||||
|
types: ['codeBlock'],
|
||||||
|
attributes: {
|
||||||
|
language: {
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
return { 'data-language': attributes.language || '' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
},
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
key: new PluginKey('codeBlockLanguageSelect'),
|
key: new PluginKey('codeBlockLanguageSelect'),
|
||||||
props: {
|
props: {
|
||||||
decorations: (state) => {
|
handleDOMEvents: {
|
||||||
const decorations: Decoration[] = [];
|
click: (view, event) => {
|
||||||
state.doc.descendants((node, pos) => {
|
const target = event.target as HTMLElement;
|
||||||
if (node.type.name === 'codeBlock') {
|
const pre = target.closest('pre');
|
||||||
const wrapper = document.createElement('div');
|
if (!pre) return false;
|
||||||
wrapper.className = 'code-lang-wrapper';
|
// Check if click is in the top-right corner (language button area)
|
||||||
|
const rect = pre.getBoundingClientRect();
|
||||||
const btn = document.createElement('button');
|
if (event.clientX < rect.right - 70 || event.clientY > rect.top + 30) return false;
|
||||||
btn.className = 'code-lang-btn';
|
// Find the code block position
|
||||||
if (node.attrs.language) {
|
const pos = view.posAtDOM(pre, 0);
|
||||||
btn.textContent = node.attrs.language;
|
const resolved = view.state.doc.resolve(pos);
|
||||||
} else {
|
let cbNode = resolved.parent;
|
||||||
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
|
let cbPos = resolved.before(resolved.depth);
|
||||||
|
if (cbNode.type.name !== 'codeBlock') {
|
||||||
|
for (let d = resolved.depth; d >= 0; d--) {
|
||||||
|
if (resolved.node(d).type.name === 'codeBlock') {
|
||||||
|
cbNode = resolved.node(d);
|
||||||
|
cbPos = resolved.before(d);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
openCodeLangDropdown(pos + 1, node.attrs.language || '', btn);
|
|
||||||
});
|
|
||||||
btn.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
||||||
|
|
||||||
wrapper.appendChild(btn);
|
|
||||||
decorations.push(Decoration.widget(pos + 1, wrapper, { side: -1, ignoreSelection: true }));
|
|
||||||
}
|
}
|
||||||
});
|
if (cbNode.type.name !== 'codeBlock') return false;
|
||||||
return DecorationSet.create(state.doc, decorations);
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
// 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,
|
||||||
|
toJSON() { return this; },
|
||||||
|
});
|
||||||
|
openCodeLangDropdown(cbPos + 1, cbNode.attrs.language || '', triggerEl as any);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1077,9 +1103,9 @@
|
|||||||
let preserveEmptyParas = false;
|
let preserveEmptyParas = false;
|
||||||
doc.forEach((node: any) => {
|
doc.forEach((node: any) => {
|
||||||
const isEmpty = node.type.name === 'paragraph' && node.childCount === 0;
|
const isEmpty = node.type.name === 'paragraph' && node.childCount === 0;
|
||||||
// After a list or code block, preserve empty paragraphs as HTML comments
|
// After a list, code block, or blockquote, preserve empty paragraphs as HTML comments
|
||||||
// so markdown-it doesn't merge adjacent lists or collapse spacing
|
// so markdown-it doesn't merge adjacent blocks or collapse spacing
|
||||||
if (listTypes.has(prevType) || prevType === 'codeBlock') {
|
if (listTypes.has(prevType) || prevType === 'codeBlock' || prevType === 'blockquote') {
|
||||||
preserveEmptyParas = true;
|
preserveEmptyParas = true;
|
||||||
}
|
}
|
||||||
if (isEmpty && preserveEmptyParas) {
|
if (isEmpty && preserveEmptyParas) {
|
||||||
@@ -1183,6 +1209,12 @@
|
|||||||
node.forEach((child: any) => {
|
node.forEach((child: any) => {
|
||||||
if (child.type.name === 'paragraph') {
|
if (child.type.name === 'paragraph') {
|
||||||
parts.push(serializeInline(child));
|
parts.push(serializeInline(child));
|
||||||
|
} else if (child.type.name === 'bulletList' || child.type.name === 'orderedList' || child.type.name === 'taskList') {
|
||||||
|
// Indent nested lists so markdown parsers recognize nesting
|
||||||
|
// Use 4 spaces — works for both bullet (- ) and ordered (1. ) parent markers
|
||||||
|
const nested = serializeNode(child).replace(/\n$/, '');
|
||||||
|
const indented = nested.split('\n').map((line: string) => ' ' + line).join('\n');
|
||||||
|
parts.push(indented);
|
||||||
} else {
|
} else {
|
||||||
parts.push(serializeNode(child));
|
parts.push(serializeNode(child));
|
||||||
}
|
}
|
||||||
@@ -1193,9 +1225,15 @@
|
|||||||
function serializeInline(node: any): string {
|
function serializeInline(node: any): string {
|
||||||
if (node.childCount === 0) return '';
|
if (node.childCount === 0) return '';
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
node.forEach((child: any) => {
|
node.forEach((child: any, _offset: number, index: number) => {
|
||||||
if (child.isText) {
|
if (child.isText) {
|
||||||
let text = child.text || '';
|
let text = child.text || '';
|
||||||
|
// Preserve leading tabs/em-spaces as HTML entities so they survive markdown roundtrip
|
||||||
|
// (markdown parsers strip tab whitespace, but   passes through as HTML)
|
||||||
|
// Tabs come from initial indent; em-spaces (U+2003) come from prior   roundtrips
|
||||||
|
if (index === 0) {
|
||||||
|
text = text.replace(/^[\t\u2003]+/, (ws) => ' '.repeat(ws.length));
|
||||||
|
}
|
||||||
// Apply marks
|
// Apply marks
|
||||||
for (const mark of child.marks) {
|
for (const mark of child.marks) {
|
||||||
switch (mark.type.name) {
|
switch (mark.type.name) {
|
||||||
@@ -1215,6 +1253,11 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'textStyle': {
|
||||||
|
const c = mark.attrs?.color;
|
||||||
|
if (c) text = `<span style="color: ${c}">${text}</span>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'link': text = `[${text}](${mark.attrs.href})`; break;
|
case 'link': text = `[${text}](${mark.attrs.href})`; break;
|
||||||
case 'wikiLink': text = `[[${mark.attrs.title || text}]]`; break;
|
case 'wikiLink': text = `[[${mark.attrs.title || text}]]`; break;
|
||||||
}
|
}
|
||||||
@@ -1485,10 +1528,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Pre-process: convert task list syntax before markdown-it (it doesn't know TipTap's format)
|
// Pre-process: convert task list syntax before markdown-it (it doesn't know TipTap's format)
|
||||||
src = src.replace(/^- \[x\][^\S\n]+(.+)$/gm, '- <tiptask checked="true">$1</tiptask>');
|
// Support indented (nested) and blockquoted task lists too
|
||||||
src = src.replace(/^- \[x\][^\S\n]*$/gm, '- <tiptask checked="true"> </tiptask>');
|
src = src.replace(/^([\s>]*)-\s\[x\][^\S\n]+(.+)$/gm, '$1- <tiptask checked="true">$2</tiptask>');
|
||||||
src = src.replace(/^- \[ \][^\S\n]+(.+)$/gm, '- <tiptask checked="false">$1</tiptask>');
|
src = src.replace(/^([\s>]*)-\s\[x\][^\S\n]*$/gm, '$1- <tiptask checked="true"> </tiptask>');
|
||||||
src = src.replace(/^- \[ \][^\S\n]*$/gm, '- <tiptask checked="false"> </tiptask>');
|
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>');
|
||||||
|
|
||||||
// Run markdown-it (single-pass parser — handles headings, bold, italic, strike, code, blockquote, lists, links, images, hr, tables, raw HTML)
|
// Run markdown-it (single-pass parser — handles headings, bold, italic, strike, code, blockquote, lists, links, images, hr, tables, raw HTML)
|
||||||
let html = mdit.render(src);
|
let html = mdit.render(src);
|
||||||
@@ -1500,11 +1544,12 @@
|
|||||||
// Post-process: convert list-separator comments back to empty paragraphs for TipTap
|
// Post-process: convert list-separator comments back to empty paragraphs for TipTap
|
||||||
html = html.replace(/<!-- -->/g, '<p></p>');
|
html = html.replace(/<!-- -->/g, '<p></p>');
|
||||||
|
|
||||||
// Post-process: convert task list items to TipTap format (handle both tight and loose lists — loose lists wrap content in <p> tags)
|
// Post-process: convert task list items to TipTap format
|
||||||
html = html.replace(/<li>\s*(?:<p>)?\s*<tiptask checked="(true|false)">([\s\S]*?)<\/tiptask>\s*(?:<\/p>)?\s*<\/li>/gi, (_, checked, content) => {
|
// Convert opening <li> + <tiptask> into data-attributed <li>, handles both tight and loose (with <p>) lists
|
||||||
return `<li data-type="taskItem" data-checked="${checked}">${content}</li>`;
|
html = html.replace(/<li>(\s*(?:<p>)?)\s*<tiptask checked="(true|false)">([\s\S]*?)<\/tiptask>\s*(?:<\/p>)?/gi, (_, _pre, checked, text) => {
|
||||||
|
return `<li data-type="taskItem" data-checked="${checked}">${text}`;
|
||||||
});
|
});
|
||||||
html = html.replace(/<ul>\s*(<li data-type="taskItem")/gi, '<ul data-type="taskList">$1');
|
html = html.replace(/<ul>(\s*<li data-type="taskItem")/gi, '<ul data-type="taskList">$1');
|
||||||
|
|
||||||
// Post-process: resolve image src paths and parse size attribute
|
// Post-process: resolve image src paths and parse size attribute
|
||||||
html = html.replace(/<img\s+src="([^"]*)"(?:\s+alt="([^"]*)")?[^>]*\/?>/gi, (_, imgSrc, altRaw) => {
|
html = html.replace(/<img\s+src="([^"]*)"(?:\s+alt="([^"]*)")?[^>]*\/?>/gi, (_, imgSrc, altRaw) => {
|
||||||
@@ -1546,6 +1591,19 @@
|
|||||||
return () => document.removeEventListener('mousedown', onClickAway);
|
return () => document.removeEventListener('mousedown', onClickAway);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close in-note search when switching notes
|
||||||
|
let prevSearchPath = '';
|
||||||
|
$effect(() => {
|
||||||
|
const path = $activeNotePath ?? '';
|
||||||
|
if (prevSearchPath && path !== prevSearchPath) {
|
||||||
|
noteSearchOpen = false;
|
||||||
|
noteSearchQuery = '';
|
||||||
|
noteSearchResults = [];
|
||||||
|
noteSearchIndex = 0;
|
||||||
|
}
|
||||||
|
prevSearchPath = path;
|
||||||
|
});
|
||||||
|
|
||||||
// React to activeNotePath changes from external sources (e.g. search panel)
|
// React to activeNotePath changes from external sources (e.g. search panel)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const path = $activeNotePath;
|
const path = $activeNotePath;
|
||||||
@@ -1617,11 +1675,31 @@
|
|||||||
content: html,
|
content: html,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: { class: 'editor-content' },
|
attributes: { class: 'editor-content' },
|
||||||
|
handleDOMEvents: {
|
||||||
|
// Prevent details toggle button from stealing focus, which causes scroll-to-top.
|
||||||
|
// Also pre-focus the editor with preventScroll so TipTap's focus command
|
||||||
|
// sees hasFocus()=true and skips its scrolling view.focus() call.
|
||||||
|
mousedown: (view, event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('[data-type="details"] > button')) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!view.hasFocus()) {
|
||||||
|
(view.dom as HTMLElement).focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
handleDrop: (_view, event) => handleFileDrop(event),
|
handleDrop: (_view, event) => handleFileDrop(event),
|
||||||
handlePaste: (_view, event) => handleFilePaste(event),
|
handlePaste: (_view, event) => handleFilePaste(event),
|
||||||
},
|
},
|
||||||
onTransaction: () => {
|
onTransaction: () => {
|
||||||
editorState++;
|
// Batch toolbar state updates to once per frame — avoids ~35 isActive() calls per transaction during selection drag
|
||||||
|
if (!editorStateRaf) {
|
||||||
|
editorStateRaf = requestAnimationFrame(() => {
|
||||||
|
editorStateRaf = 0;
|
||||||
|
editorState++;
|
||||||
|
});
|
||||||
|
}
|
||||||
updateSlashMenu();
|
updateSlashMenu();
|
||||||
updateWikiLinkMenu();
|
updateWikiLinkMenu();
|
||||||
},
|
},
|
||||||
@@ -4123,48 +4201,43 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
overflow-x: auto;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap pre code) {
|
:global(.tiptap-wrapper .tiptap pre code) {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap pre .code-lang-wrapper) {
|
:global(.tiptap-wrapper .tiptap pre)::after {
|
||||||
|
content: attr(data-language);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap pre .code-lang-btn) {
|
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
opacity: 0;
|
||||||
opacity: 0.4;
|
pointer-events: none;
|
||||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap pre:hover .code-lang-btn) {
|
:global(.tiptap-wrapper .tiptap pre[data-language=""])::after {
|
||||||
|
content: '•••';
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tiptap-wrapper .tiptap pre:hover)::after {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap pre .code-lang-btn:hover) {
|
|
||||||
opacity: 1;
|
|
||||||
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-lang-overlay {
|
.code-lang-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, installType, settingsTab } from '$lib/stores/app';
|
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab } from '$lib/stores/app';
|
||||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
|
import { setTheme, setAccentColor, setFontSize, setFontFamily, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
|
||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
@@ -41,6 +41,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-populate updater object from global store so Download & Install works without manual check
|
||||||
|
$effect(() => {
|
||||||
|
const obj = $globalUpdateObj;
|
||||||
|
if (obj && !updateObj) {
|
||||||
|
updateObj = obj;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function handleCheckUpdate() {
|
async function handleCheckUpdate() {
|
||||||
updateChecking = true;
|
updateChecking = true;
|
||||||
updateMessage = null;
|
updateMessage = null;
|
||||||
@@ -49,6 +57,7 @@
|
|||||||
const update = await checkUpdate();
|
const update = await checkUpdate();
|
||||||
if (update) {
|
if (update) {
|
||||||
updateObj = update;
|
updateObj = update;
|
||||||
|
$globalUpdateObj = update;
|
||||||
updateAvailable = { version: update.version, body: update.body, date: update.date };
|
updateAvailable = { version: update.version, body: update.body, date: update.date };
|
||||||
globalUpdateAvailable.set({ version: update.version, body: update.body });
|
globalUpdateAvailable.set({ version: update.version, body: update.body });
|
||||||
updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
|
updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
|
||||||
@@ -1052,7 +1061,7 @@
|
|||||||
<div class="update-notes">{updateAvailable.body}</div>
|
<div class="update-notes">{updateAvailable.body}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $installType === 'appimage' || $installType === 'windows'}
|
{#if $installType === 'appimage' || $installType === 'windows' || $installType === 'macos'}
|
||||||
<button class="update-install-btn" onclick={handleDownloadAndInstall} disabled={updateDownloading}>
|
<button class="update-install-btn" onclick={handleDownloadAndInstall} disabled={updateDownloading}>
|
||||||
{#if updateDownloading}
|
{#if updateDownloading}
|
||||||
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||||
@@ -1074,6 +1083,11 @@
|
|||||||
<p>Update via your package manager:</p>
|
<p>Update via your package manager:</p>
|
||||||
<code>sudo apt update && sudo apt upgrade helix-notes</code>
|
<code>sudo apt update && sudo apt upgrade helix-notes</code>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if $installType === 'aur'}
|
||||||
|
<div class="update-apt-info">
|
||||||
|
<p>Update via your AUR helper:</p>
|
||||||
|
<code>yay -Syu helixnotes</code>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener">
|
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
quickAccessPaths,
|
quickAccessPaths,
|
||||||
collapsedNotebooks
|
collapsedNotebooks
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api';
|
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api';
|
||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { readFile } from '@tauri-apps/plugin-fs';
|
import { readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
let newNotebookName = $state('');
|
let newNotebookName = $state('');
|
||||||
let showNewNotebook = $state(false);
|
let showNewNotebook = $state(false);
|
||||||
let dropTargetPath = $state<string | null>(null);
|
let dropTargetPath = $state<string | null>(null);
|
||||||
|
let draggedNotebookPath = $state<string | null>(null);
|
||||||
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
|
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
|
||||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
function toggleCollapse(path: string, e: MouseEvent) {
|
function toggleCollapse(path: string, e: MouseEvent) {
|
||||||
@@ -150,6 +151,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropTargetPath = null;
|
||||||
|
const srcPath = e.dataTransfer?.getData('text/plain');
|
||||||
|
if (!srcPath) return;
|
||||||
|
draggedNotebookPath = null;
|
||||||
|
// Don't move onto self or descendant
|
||||||
|
if (destPath === srcPath || destPath.startsWith(srcPath + '/')) return;
|
||||||
|
// Don't move if already in that parent
|
||||||
|
const parentDir = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
||||||
|
if (parentDir === destPath) return;
|
||||||
|
try {
|
||||||
|
const oldName = srcPath.split('/').pop() || '';
|
||||||
|
const newBasePath = destPath + '/' + oldName;
|
||||||
|
await moveNotebook(srcPath, destPath);
|
||||||
|
// Update collapsedNotebooks paths
|
||||||
|
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||||
|
if (p === srcPath) return newBasePath;
|
||||||
|
if (p.startsWith(srcPath + '/')) return newBasePath + p.slice(srcPath.length);
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
// Update active notebook/note if inside the moved notebook
|
||||||
|
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
||||||
|
selectAllNotes();
|
||||||
|
}
|
||||||
|
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
||||||
|
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
|
||||||
|
$activeNotePath = newNotePath;
|
||||||
|
$activeNote = await readNote(newNotePath);
|
||||||
|
}
|
||||||
|
$notebookIcons = await getNotebookIcons();
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to move notebook:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startRename(nb: NotebookEntry) {
|
function startRename(nb: NotebookEntry) {
|
||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
editingNotebook = nb.path;
|
editingNotebook = nb.path;
|
||||||
@@ -292,7 +330,25 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="section-header"
|
||||||
|
class:drop-target={dropTargetPath === '__root__'}
|
||||||
|
ondragover={(e) => {
|
||||||
|
if (draggedNotebookPath) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer!.dropEffect = 'move';
|
||||||
|
dropTargetPath = '__root__';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ondragleave={() => { if (dropTargetPath === '__root__') dropTargetPath = null; }}
|
||||||
|
ondrop={(e) => {
|
||||||
|
if (draggedNotebookPath) {
|
||||||
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
|
if (vaultRoot) handleNotebookDrop(e, vaultRoot);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span class="section-title">Notebooks</span>
|
<span class="section-title">Notebooks</span>
|
||||||
<button class="icon-btn-sm" onclick={() => (showNewNotebook = !showNewNotebook)} title="New notebook">
|
<button class="icon-btn-sm" onclick={() => (showNewNotebook = !showNewNotebook)} title="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">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -423,11 +479,34 @@
|
|||||||
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
|
||||||
class:drop-target={dropTargetPath === nb.path}
|
class:drop-target={dropTargetPath === nb.path}
|
||||||
style="padding-left: {8 + depth * 16}px"
|
style="padding-left: {8 + depth * 16}px"
|
||||||
|
draggable="true"
|
||||||
onclick={() => selectNotebook(nb)}
|
onclick={() => selectNotebook(nb)}
|
||||||
oncontextmenu={(e) => onContextMenu(e, nb)}
|
oncontextmenu={(e) => onContextMenu(e, nb)}
|
||||||
ondragover={(e) => { e.preventDefault(); e.dataTransfer!.dropEffect = 'move'; dropTargetPath = nb.path; }}
|
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
|
||||||
|
ondragend={() => { draggedNotebookPath = null; }}
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedNotebookPath) {
|
||||||
|
// Prevent drop on self or descendant
|
||||||
|
if (nb.path === draggedNotebookPath || nb.path.startsWith(draggedNotebookPath + '/')) {
|
||||||
|
e.dataTransfer!.dropEffect = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prevent no-op (already in this parent)
|
||||||
|
const parentDir = draggedNotebookPath.substring(0, draggedNotebookPath.lastIndexOf('/'));
|
||||||
|
if (parentDir === nb.path) { e.dataTransfer!.dropEffect = 'none'; return; }
|
||||||
|
}
|
||||||
|
e.dataTransfer!.dropEffect = 'move';
|
||||||
|
dropTargetPath = nb.path;
|
||||||
|
}}
|
||||||
ondragleave={() => { if (dropTargetPath === nb.path) dropTargetPath = null; }}
|
ondragleave={() => { if (dropTargetPath === nb.path) dropTargetPath = null; }}
|
||||||
ondrop={(e) => handleNoteDrop(e, nb)}
|
ondrop={(e) => {
|
||||||
|
if (draggedNotebookPath) {
|
||||||
|
handleNotebookDrop(e, nb.path);
|
||||||
|
} else {
|
||||||
|
handleNoteDrop(e, nb);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if hasChildren}
|
{#if hasChildren}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -610,7 +689,8 @@
|
|||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notebook-item.drop-target {
|
.notebook-item.drop-target,
|
||||||
|
.section-header.drop-target {
|
||||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
outline: 2px dashed var(--accent);
|
outline: 2px dashed var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
|
|||||||
@@ -206,7 +206,7 @@
|
|||||||
padding: 4px 12px 4px 10px;
|
padding: 4px 12px 4px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--accent);
|
background: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const updateAvailable = writable<{
|
|||||||
version: string;
|
version: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
export const updateObj = writable<any>(null);
|
||||||
export const installType = writable<string>("native");
|
export const installType = writable<string>("native");
|
||||||
|
|
||||||
export async function checkForUpdate() {
|
export async function checkForUpdate() {
|
||||||
@@ -59,6 +60,7 @@ export async function checkForUpdate() {
|
|||||||
const update = await check();
|
const update = await check();
|
||||||
if (update) {
|
if (update) {
|
||||||
updateAvailable.set({ version: update.version, body: update.body });
|
updateAvailable.set({ version: update.version, body: update.body });
|
||||||
|
updateObj.set(update);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silent fail — don't disrupt app startup
|
// Silent fail — don't disrupt app startup
|
||||||
|
|||||||
Reference in New Issue
Block a user