notebook drag-and-drop, nested list fix, macOS updates, search bar auto-close

This commit is contained in:
Yuri Karamian
2026-02-13 23:35:30 +01:00
parent f32f77dcd5
commit a88fcbefba
14 changed files with 385 additions and 70 deletions
+4 -3
View File
@@ -8,9 +8,9 @@ Your notes are stored as standard Markdown files on your local filesystem. No cl
| 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 (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+ |
| 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 |
| 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.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.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 | |
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
- **Backups** — automatic zip-based vault backups
- **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
- **Themes** — light/dark mode with customizable accent colors and fonts
- **Focus mode** — distraction-free writing
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.0.8",
"version": "1.0.9",
"type": "module",
"scripts": {
"dev": "vite dev",
+17 -1
View File
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.0.8"
version = "1.0.9"
dependencies = [
"chrono",
"dirs",
@@ -1799,6 +1799,7 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"uuid",
@@ -5081,6 +5082,21 @@ dependencies = [
"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]]
name = "tauri-plugin-updater"
version = "2.10.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.0.8"
version = "1.0.9"
description = "Local-first markdown note-taking app"
authors = ["HelixNotes"]
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"] }
futures = "0.3"
rayon = "1"
tauri-plugin-single-instance = "2"
+82 -1
View File
@@ -102,6 +102,77 @@ pub fn rename_notebook(path: String, new_name: String) -> Result<String, String>
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(&notebook_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(&notebook_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]
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), 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]
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()
} else if std::env::var("APPIMAGE").is_ok() {
"appimage".to_string()
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
"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 {
"native".to_string()
}
+8
View File
@@ -23,6 +23,13 @@ pub fn run() {
let app_state = AppState::new(config);
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_fs::init())
.plugin(tauri_plugin_opener::init())
@@ -54,6 +61,7 @@ pub fn run() {
commands::create_notebook,
commands::rename_notebook,
commands::delete_notebook,
commands::move_notebook,
commands::get_notes,
commands::read_note,
commands::save_note,
+32
View File
@@ -432,6 +432,38 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, 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> {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.0.8",
"version": "1.0.9",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+7
View File
@@ -60,6 +60,13 @@ export async function deleteNotebook(path: string): Promise<void> {
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(
notebookPath: string | null,
): Promise<NoteEntry[]> {
+125 -52
View File
@@ -75,6 +75,7 @@
let anyDropdownOpen = $derived(headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen);
let editorState = $state(0);
let editorStateRaf = 0; // RAF handle for batching toolbar updates
// AI
let aiMenu = $state<{ x: number; y: number } | null>(null);
@@ -169,6 +170,8 @@
.use(markdownItMark)
.use(markdownItSup)
.use(markdownItSub);
// Disable indented code blocks — tab-indented text should stay as text, not become code
mdit.disable('code');
function normalizePath(p: string): string {
const parts = p.split('/');
@@ -313,37 +316,60 @@
const CodeBlockLanguageSelect = Extension.create({
name: 'codeBlockLanguageSelect',
addGlobalAttributes() {
return [{
types: ['codeBlock'],
attributes: {
language: {
renderHTML: (attributes) => {
return { 'data-language': attributes.language || '' };
},
},
},
}];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('codeBlockLanguageSelect'),
props: {
decorations: (state) => {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name === 'codeBlock') {
const wrapper = document.createElement('div');
wrapper.className = 'code-lang-wrapper';
const btn = document.createElement('button');
btn.className = 'code-lang-btn';
if (node.attrs.language) {
btn.textContent = node.attrs.language;
} else {
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>';
handleDOMEvents: {
click: (view, event) => {
const target = event.target as HTMLElement;
const pre = target.closest('pre');
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;
// Find the code block position
const pos = view.posAtDOM(pre, 0);
const resolved = view.state.doc.resolve(pos);
let cbNode = resolved.parent;
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;
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; },
});
return DecorationSet.create(state.doc, decorations);
openCodeLangDropdown(cbPos + 1, cbNode.attrs.language || '', triggerEl as any);
return true;
},
},
},
}),
@@ -1077,9 +1103,9 @@
let preserveEmptyParas = false;
doc.forEach((node: any) => {
const isEmpty = node.type.name === 'paragraph' && node.childCount === 0;
// After a list or code block, preserve empty paragraphs as HTML comments
// so markdown-it doesn't merge adjacent lists or collapse spacing
if (listTypes.has(prevType) || prevType === 'codeBlock') {
// After a list, code block, or blockquote, preserve empty paragraphs as HTML comments
// so markdown-it doesn't merge adjacent blocks or collapse spacing
if (listTypes.has(prevType) || prevType === 'codeBlock' || prevType === 'blockquote') {
preserveEmptyParas = true;
}
if (isEmpty && preserveEmptyParas) {
@@ -1183,6 +1209,12 @@
node.forEach((child: any) => {
if (child.type.name === 'paragraph') {
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 {
parts.push(serializeNode(child));
}
@@ -1193,9 +1225,15 @@
function serializeInline(node: any): string {
if (node.childCount === 0) return '';
const parts: string[] = [];
node.forEach((child: any) => {
node.forEach((child: any, _offset: number, index: number) => {
if (child.isText) {
let text = child.text || '';
// Preserve leading tabs/em-spaces as HTML entities so they survive markdown roundtrip
// (markdown parsers strip tab whitespace, but &emsp; passes through as HTML)
// Tabs come from initial indent; em-spaces (U+2003) come from prior &emsp; roundtrips
if (index === 0) {
text = text.replace(/^[\t\u2003]+/, (ws) => '&emsp;'.repeat(ws.length));
}
// Apply marks
for (const mark of child.marks) {
switch (mark.type.name) {
@@ -1215,6 +1253,11 @@
}
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 '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)
src = src.replace(/^- \[x\][^\S\n]+(.+)$/gm, '- <tiptask checked="true">$1</tiptask>');
src = src.replace(/^- \[x\][^\S\n]*$/gm, '- <tiptask checked="true">&nbsp;</tiptask>');
src = src.replace(/^- \[ \][^\S\n]+(.+)$/gm, '- <tiptask checked="false">$1</tiptask>');
src = src.replace(/^- \[ \][^\S\n]*$/gm, '- <tiptask checked="false">&nbsp;</tiptask>');
// Support indented (nested) and blockquoted task lists too
src = src.replace(/^([\s>]*)-\s\[x\][^\S\n]+(.+)$/gm, '$1- <tiptask checked="true">$2</tiptask>');
src = src.replace(/^([\s>]*)-\s\[x\][^\S\n]*$/gm, '$1- <tiptask checked="true">&nbsp;</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">&nbsp;</tiptask>');
// 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);
@@ -1500,11 +1544,12 @@
// Post-process: convert list-separator comments back to empty paragraphs for TipTap
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)
html = html.replace(/<li>\s*(?:<p>)?\s*<tiptask checked="(true|false)">([\s\S]*?)<\/tiptask>\s*(?:<\/p>)?\s*<\/li>/gi, (_, checked, content) => {
return `<li data-type="taskItem" data-checked="${checked}">${content}</li>`;
// Post-process: convert task list items to TipTap format
// Convert opening <li> + <tiptask> into data-attributed <li>, handles both tight and loose (with <p>) lists
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
html = html.replace(/<img\s+src="([^"]*)"(?:\s+alt="([^"]*)")?[^>]*\/?>/gi, (_, imgSrc, altRaw) => {
@@ -1546,6 +1591,19 @@
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)
$effect(() => {
const path = $activeNotePath;
@@ -1617,11 +1675,31 @@
content: html,
editorProps: {
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),
handlePaste: (_view, event) => handleFilePaste(event),
},
onTransaction: () => {
// 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();
updateWikiLinkMenu();
},
@@ -4123,48 +4201,43 @@
border-radius: 8px;
padding: 16px;
margin: 1em 0;
overflow-x: auto;
position: relative;
}
:global(.tiptap-wrapper .tiptap pre code) {
display: block;
overflow-x: auto;
background: none;
padding: 0;
font-size: 13px;
line-height: 1.5;
}
:global(.tiptap-wrapper .tiptap pre .code-lang-wrapper) {
:global(.tiptap-wrapper .tiptap pre)::after {
content: attr(data-language);
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
}
:global(.tiptap-wrapper .tiptap pre .code-lang-btn) {
padding: 2px 6px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
font-size: 11px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
cursor: pointer;
outline: none;
opacity: 0.4;
transition: opacity 0.15s, background 0.15s, color 0.15s;
opacity: 0;
pointer-events: none;
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;
}
: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 {
position: fixed;
inset: 0;
+16 -2
View File
@@ -1,5 +1,5 @@
<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 { open as openDialog } from '@tauri-apps/plugin-dialog';
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() {
updateChecking = true;
updateMessage = null;
@@ -49,6 +57,7 @@
const update = await checkUpdate();
if (update) {
updateObj = update;
$globalUpdateObj = update;
updateAvailable = { version: update.version, body: update.body, date: update.date };
globalUpdateAvailable.set({ version: update.version, body: update.body });
updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
@@ -1052,7 +1061,7 @@
<div class="update-notes">{updateAvailable.body}</div>
{/if}
</div>
{#if $installType === 'appimage' || $installType === 'windows'}
{#if $installType === 'appimage' || $installType === 'windows' || $installType === 'macos'}
<button class="update-install-btn" onclick={handleDownloadAndInstall} disabled={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>
@@ -1074,6 +1083,11 @@
<p>Update via your package manager:</p>
<code>sudo apt update && sudo apt upgrade helix-notes</code>
</div>
{:else if $installType === 'aur'}
<div class="update-apt-info">
<p>Update via your AUR helper:</p>
<code>yay -Syu helixnotes</code>
</div>
{:else}
<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">
+85 -5
View File
@@ -17,7 +17,7 @@
quickAccessPaths,
collapsedNotebooks
} 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 { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core';
@@ -32,6 +32,7 @@
let newNotebookName = $state('');
let showNewNotebook = $state(false);
let dropTargetPath = $state<string | null>(null);
let draggedNotebookPath = $state<string | null>(null);
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
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) {
contextMenu = null;
editingNotebook = nb.path;
@@ -292,7 +330,25 @@
</nav>
<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>
<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">
@@ -423,11 +479,34 @@
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
class:drop-target={dropTargetPath === nb.path}
style="padding-left: {8 + depth * 16}px"
draggable="true"
onclick={() => selectNotebook(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; }}
ondrop={(e) => handleNoteDrop(e, nb)}
ondrop={(e) => {
if (draggedNotebookPath) {
handleNotebookDrop(e, nb.path);
} else {
handleNoteDrop(e, nb);
}
}}
>
{#if hasChildren}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -610,7 +689,8 @@
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);
outline: 2px dashed var(--accent);
outline-offset: -2px;
+1 -1
View File
@@ -206,7 +206,7 @@
padding: 4px 12px 4px 10px;
border: none;
border-radius: 7px;
background: var(--accent);
background: color-mix(in srgb, var(--accent) 85%, black);
color: white;
font-size: 11.5px;
font-weight: 600;
+2
View File
@@ -51,6 +51,7 @@ export const updateAvailable = writable<{
version: string;
body?: string;
} | null>(null);
export const updateObj = writable<any>(null);
export const installType = writable<string>("native");
export async function checkForUpdate() {
@@ -59,6 +60,7 @@ export async function checkForUpdate() {
const update = await check();
if (update) {
updateAvailable.set({ version: update.version, body: update.body });
updateObj.set(update);
}
} catch {
// Silent fail — don't disrupt app startup