Compare commits

...
4 Commits
Author SHA1 Message Date
Yuri Karamian f688cf4bd4 v1.0.4 - Quick Access reorder, performance improvements, search fixes
- Quick Access drag-to-reorder: drag notes to rearrange, order persists
- Quick Access preserves order when moving notes between notebooks
- In-note search: debounced input, proper scroll-to-match
- Panel resize: RAF-batched updates, editor containment during resize
- Thinner resize handles (3px)
- Note list: removed transition, added CSS containment for 1200+ notes
- Escape closes Settings and Info panels
- Fix Quick Access cache invalidation on remove
2026-02-10 21:14:50 +01:00
Yuri Karamian a95e240bcd Update README download links to v1.0.3 2026-02-10 20:17:49 +01:00
Yuri Karamian bbcca553ce v1.0.3 - In-note search, note history navigation, keyboard shortcuts
- Find in note (Ctrl+F): search within current note with match highlighting and navigation
- Global vault search moved to Ctrl+Shift+F
- Note history navigation: Alt+Left/Right and mouse back/forward buttons
- Search icon added to editor toolbar
- Escape closes Settings and Info panels
- Updated Info panel with new keyboard shortcuts
- Deb install type detection with APT upgrade instructions
2026-02-10 19:38:51 +01:00
Yuri Karamian 6a40f553f1 v1.0.2 - Update banner, smart updater, install type detection 2026-02-10 01:31:54 +01:00
18 changed files with 599 additions and 54 deletions
+6 -5
View File
@@ -6,11 +6,12 @@ Your notes are stored as standard Markdown files on your local filesystem. No cl
## Download
| Platform | Download |
|----------|----------|
| Linux | [HelixNotes_1.0.1_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.1/HelixNotes_1.0.1_amd64.AppImage) |
| Windows | [HelixNotes_1.0.1_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.1/HelixNotes_1.0.1_x64-setup.exe) |
| macOS | Coming soon |
| Platform | Download | Notes |
|----------|----------|-------|
| Linux (Arch/rolling) | [HelixNotes_1.0.3_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.3/HelixNotes_1.0.3_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.0.3_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.3/HelixNotes_1.0.3_amd64.deb) | Ubuntu 22.04+ |
| Windows | [HelixNotes_1.0.3_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.3/HelixNotes_1.0.3_x64-setup.exe) | Windows 10/11 |
| macOS | Coming soon | |
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.0.1",
"version": "1.0.4",
"type": "module",
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.0.1"
version = "1.0.4"
dependencies = [
"chrono",
"dirs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.0.1"
version = "1.0.4"
description = "Local-first markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
+56 -2
View File
@@ -2,6 +2,7 @@ use crate::search::SearchIndex;
use crate::state::AppState;
use crate::types::*;
use crate::vault::{operations, watcher};
use std::path::Path;
use tauri::{AppHandle, Manager, State};
// ── Vault Management ──
@@ -195,8 +196,39 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin
}
#[tauri::command]
pub fn move_note(note_path: String, dest_notebook: String) -> Result<String, String> {
operations::move_note(&note_path, &dest_notebook)
pub fn move_note(
state: State<'_, AppState>,
note_path: String,
dest_notebook: 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")?;
// Compute old relative path before move
let old_relative = Path::new(&note_path)
.strip_prefix(vault_path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let new_full_path = operations::move_note(&note_path, &dest_notebook)?;
// Update quick access if the moved note was in it
if !old_relative.is_empty() {
if let Ok(mut qa) = operations::load_quick_access(vault_path) {
if let Some(pos) = qa.iter().position(|p| *p == old_relative) {
let new_relative = Path::new(&new_full_path)
.strip_prefix(vault_path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if !new_relative.is_empty() {
qa[pos] = new_relative;
let _ = operations::save_quick_access(vault_path, &qa);
}
}
}
}
Ok(new_full_path)
}
// ── Tags ──
@@ -437,6 +469,13 @@ pub fn remove_quick_access(
operations::remove_quick_access(vault_path, &note_relative)
}
#[tauri::command]
pub fn reorder_quick_access(state: State<'_, AppState>, paths: Vec<String>) -> Result<(), 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")?;
operations::save_quick_access(vault_path, &paths)
}
#[tauri::command]
pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
@@ -1341,3 +1380,18 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> {
std::fs::write(path, data).map_err(|e| e.to_string())?;
Ok(())
}
// ── Install Type Detection ──
#[tauri::command]
pub fn get_install_type() -> String {
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 {
"native".to_string()
}
}
+2
View File
@@ -79,6 +79,7 @@ pub fn run() {
commands::get_quick_access,
commands::add_quick_access,
commands::remove_quick_access,
commands::reorder_quick_access,
commands::get_vault_stats,
commands::import_obsidian,
commands::open_file,
@@ -94,6 +95,7 @@ pub fn run() {
commands::set_ai_settings,
commands::test_ai_connection,
commands::ai_ask,
commands::get_install_type,
]);
if close_to_tray {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.0.1",
"version": "1.0.4",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+10 -1
View File
@@ -31,7 +31,7 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--sidebar-width: 220px;
--notelist-width: 280px;
--panel-resize-handle: 4px;
--panel-resize-handle: 3px;
}
:root.dark {
@@ -149,3 +149,12 @@ body {
.resize-handle.active {
background: var(--accent);
}
body.resizing .ProseMirror {
pointer-events: none;
contain: strict;
}
body.resizing {
user-select: none;
}
+8
View File
@@ -213,6 +213,10 @@ export async function removeQuickAccess(noteRelative: string): Promise<void> {
return invoke("remove_quick_access", { noteRelative });
}
export async function reorderQuickAccess(paths: string[]): Promise<void> {
return invoke("reorder_quick_access", { paths });
}
export async function getVaultStats(): Promise<VaultStats> {
return invoke("get_vault_stats");
}
@@ -309,3 +313,7 @@ export async function aiAsk(
): Promise<void> {
return invoke("ai_ask", { action, text, customPrompt, requestId });
}
export async function getInstallType(): Promise<string> {
return invoke("get_install_type");
}
+69 -5
View File
@@ -20,11 +20,15 @@
showCommandPalette,
theme,
focusMode,
activeNote
activeNote,
activeNotePath,
editorDirty,
showInfo,
showSettings
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
import { loadVaultState, saveVaultState } from '$lib/api';
import { loadVaultState, saveVaultState, readNote } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import type { VaultState, FileEvent } from '$lib/types';
@@ -32,6 +36,39 @@
let noteList: NoteList;
let editor: Editor;
let unlistenFileChange: (() => void) | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
// Track note navigation in history stack
$effect(() => {
const path = $activeNotePath;
if (navigatingFromHistory) {
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
});
function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction;
if (newIndex < 0 || newIndex >= noteHistory.length) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => {
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
editor?.loadNote(path, content.content);
}).catch(() => {
// Note may have been deleted, ignore
});
}
const persistState = debounce(async () => {
const state: VaultState = {
@@ -69,7 +106,22 @@
editor?.focusTitle();
}
function handleMouseDown(e: MouseEvent) {
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
}
function handleKeydown(e: KeyboardEvent) {
if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
navigateHistory(-1);
return;
}
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
navigateHistory(1);
return;
}
if (e.ctrlKey && !e.shiftKey && e.key === 'n') {
e.preventDefault();
createAndFocusNote();
@@ -77,9 +129,19 @@
if (e.ctrlKey && e.shiftKey && e.key === 'N') {
e.preventDefault();
}
if (e.ctrlKey && e.key === 'f') {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
$showSearch = true;
return;
}
if (e.ctrlKey && !e.shiftKey && e.key === 'f') {
e.preventDefault();
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
}
if (e.ctrlKey && e.key === 'p') {
e.preventDefault();
@@ -90,7 +152,9 @@
editor?.forceSave();
}
if (e.key === 'Escape') {
if ($focusMode) $focusMode = false;
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
else if ($focusMode) $focusMode = false;
else if ($showSearch) $showSearch = false;
else if ($showCommandPalette) $showCommandPalette = false;
}
@@ -142,7 +206,7 @@
});
</script>
<svelte:window onkeydown={handleKeydown} />
<svelte:window onkeydown={handleKeydown} onmousedown={handleMouseDown} />
<div class="app-shell">
{#if $focusMode}
+229 -5
View File
@@ -93,6 +93,14 @@
let historySelected = $state<VersionEntry | null>(null);
let historyLoading = $state(false);
// In-note search
let noteSearchOpen = $state(false);
let noteSearchQuery = $state('');
let noteSearchIndex = $state(0);
let noteSearchResults = $state<{from: number, to: number}[]>([]);
let noteSearchInput = $state<HTMLInputElement>(null!);
const noteSearchPluginKey = new PluginKey('noteSearch');
// Slash commands
let slashMenu = $state<{ x: number; y: number; query: string; from: number; to: number } | null>(null);
let slashSelectedIndex = $state(0);
@@ -267,6 +275,31 @@
codeLangDropdown = null;
}
// ── In-note search extension ──
const NoteSearchExtension = Extension.create({
name: 'noteSearch',
addProseMirrorPlugins() {
return [
new Plugin({
key: noteSearchPluginKey,
state: {
init() { return DecorationSet.empty; },
apply(tr, old) {
const meta = tr.getMeta(noteSearchPluginKey);
if (meta !== undefined) return meta;
return old.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
const CodeBlockLanguageSelect = Extension.create({
name: 'codeBlockLanguageSelect',
addProseMirrorPlugins() {
@@ -397,7 +430,14 @@
closeSlashMenu();
return true;
}
if (event.key === 'ArrowRight' || event.key === 'Tab') {
if (event.key === 'Tab') {
event.preventDefault();
if (slashTableHover.rows > 0 && slashTableHover.cols > 0) {
slashInsertTable(slashTableHover.rows, slashTableHover.cols);
}
return true;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
slashTableHover = { rows: Math.max(1, slashTableHover.rows), cols: Math.min(10, (slashTableHover.cols || 0) + 1) };
return true;
@@ -436,7 +476,7 @@
slashSelectedIndex = (slashSelectedIndex - 1 + slashFiltered.length) % Math.max(1, slashFiltered.length);
return true;
}
if (event.key === 'Enter') {
if (event.key === 'Enter' || event.key === 'Tab') {
if (slashFiltered.length > 0) {
event.preventDefault();
executeSlashCommand(slashSelectedIndex);
@@ -1038,9 +1078,12 @@
return '```' + lang + '\n' + code + '\n```\n';
}
case 'blockquote': {
const inner: string[] = [];
node.forEach((child: any) => inner.push(serializeNode(child)));
return inner.join('').split('\n').filter((l: string) => l !== '').map((l: string) => '> ' + l).join('\n') + '\n';
const blocks: string[] = [];
node.forEach((child: any) => {
const lines = serializeNode(child).replace(/\n$/, '').split('\n');
blocks.push(lines.map((l: string) => '> ' + l).join('\n'));
});
return blocks.join('\n>\n') + '\n';
}
case 'bulletList': {
const items: string[] = [];
@@ -1144,6 +1187,91 @@
return parts.join('');
}
function autofocus(el: HTMLElement) {
requestAnimationFrame(() => el.focus());
}
// ── In-note search functions ──
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
function updateNoteSearch(query: string) {
if (noteSearchTimer) clearTimeout(noteSearchTimer);
if (!editor) return;
if (!query) {
noteSearchResults = [];
noteSearchIndex = 0;
const tr = editor.state.tr.setMeta(noteSearchPluginKey, DecorationSet.empty);
editor.view.dispatch(tr);
return;
}
noteSearchTimer = setTimeout(() => {
if (!editor) return;
const results: {from: number, to: number}[] = [];
const lowerQuery = query.toLowerCase();
editor.state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
const text = node.text.toLowerCase();
let idx = text.indexOf(lowerQuery);
while (idx !== -1) {
results.push({ from: pos + idx, to: pos + idx + query.length });
idx = text.indexOf(lowerQuery, idx + 1);
}
});
noteSearchResults = results;
if (noteSearchIndex >= results.length) noteSearchIndex = 0;
applySearchDecorations();
}, 100);
}
function applySearchDecorations() {
if (!editor) return;
const decorations = noteSearchResults.map((m, i) =>
Decoration.inline(m.from, m.to, { class: i === noteSearchIndex ? 'note-search-match note-search-active' : 'note-search-match' })
);
const decoSet = DecorationSet.create(editor.state.doc, decorations);
const tr = editor.state.tr.setMeta(noteSearchPluginKey, decoSet);
editor.view.dispatch(tr);
scrollToCurrentMatch();
}
function scrollToCurrentMatch() {
if (!editor || noteSearchResults.length === 0) return;
requestAnimationFrame(() => {
const el = editor?.view.dom.querySelector('.note-search-active');
if (el) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
function noteSearchNext() {
if (noteSearchResults.length === 0) return;
noteSearchIndex = (noteSearchIndex + 1) % noteSearchResults.length;
applySearchDecorations();
}
function noteSearchPrev() {
if (noteSearchResults.length === 0) return;
noteSearchIndex = (noteSearchIndex - 1 + noteSearchResults.length) % noteSearchResults.length;
applySearchDecorations();
}
export function openNoteSearch() {
noteSearchOpen = true;
}
function closeNoteSearch() {
noteSearchOpen = false;
noteSearchQuery = '';
noteSearchResults = [];
noteSearchIndex = 0;
if (editor) {
const tr = editor.state.tr.setMeta(noteSearchPluginKey, DecorationSet.empty);
editor.view.dispatch(tr);
editor.commands.focus();
}
}
function stripAssetSrc(src: string): string {
// blob: URLs are not persistable — they were temporary browser references
if (src.startsWith('blob:')) return '';
@@ -1418,6 +1546,7 @@
DetailsContent,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
SlashCommands,
NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
],
content: html,
@@ -2294,6 +2423,16 @@
{#if readOnly}
<span class="readonly-indicator">View Mode</span>
{/if}
<button
class="icon-btn"
class:active={noteSearchOpen}
onclick={() => noteSearchOpen ? closeNoteSearch() : openNoteSearch()}
title="Find in note (Ctrl+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>
<button
class="icon-btn"
class:active={readOnly}
@@ -2401,6 +2540,40 @@
</div>
<div class="editor-body-wrapper">
{#if noteSearchOpen}
<div class="note-search-bar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5;flex-shrink:0"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
bind:this={noteSearchInput}
type="text"
class="note-search-input"
placeholder="Find in note..."
bind:value={noteSearchQuery}
oninput={() => updateNoteSearch(noteSearchQuery)}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? noteSearchPrev() : noteSearchNext(); }
if (e.key === 'Escape') { e.preventDefault(); closeNoteSearch(); }
}}
use:autofocus
/>
<span class="note-search-count">
{#if noteSearchQuery && noteSearchResults.length > 0}
{noteSearchIndex + 1} / {noteSearchResults.length}
{:else if noteSearchQuery}
No results
{/if}
</span>
<button class="note-search-btn" onclick={noteSearchPrev} title="Previous (Shift+Enter)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button class="note-search-btn" onclick={noteSearchNext} title="Next (Enter)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="note-search-btn" onclick={closeNoteSearch} title="Close (Esc)">
<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="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/if}
<div class="editor-body">
{#if $sourceMode}
<textarea
@@ -3184,6 +3357,7 @@
placeholder="Tell AI what to do with the selected text..."
bind:value={aiCustomPrompt}
onkeydown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runAiAction('custom', aiCustomPrompt); } }}
use:autofocus
></textarea>
<button class="ai-custom-submit" onclick={() => runAiAction('custom', aiCustomPrompt)} disabled={!aiCustomPrompt.trim()}>
<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="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>
@@ -3212,6 +3386,7 @@
placeholder="Describe the note you want to create..."
bind:value={aiCustomPrompt}
onkeydown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runAiAction('custom', aiCustomPrompt); } }}
use:autofocus
></textarea>
<button class="ai-custom-submit" onclick={() => runAiAction('custom', aiCustomPrompt)} disabled={!aiCustomPrompt.trim()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/></svg>
@@ -3600,9 +3775,58 @@
.editor-body-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.note-search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.note-search-input {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 13px;
outline: none;
min-width: 0;
}
.note-search-input::placeholder {
color: var(--text-secondary);
}
.note-search-count {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.note-search-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
}
.note-search-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
:global(.note-search-match) {
background: rgba(255, 200, 0, 0.3);
border-radius: 2px;
}
:global(.note-search-active) {
background: rgba(255, 150, 0, 0.6);
}
.editor-body {
flex: 1;
overflow-y: auto;
+4 -1
View File
@@ -114,13 +114,16 @@
<h4 class="shortcuts-group-title">Keyboard Shortcuts</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>I</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>U</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<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>
<div class="shortcut-row"><span class="shortcut-desc">Exit focus mode</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4>
+65 -3
View File
@@ -26,6 +26,7 @@
getQuickAccess,
addQuickAccess,
removeQuickAccess,
reorderQuickAccess,
moveNote
} from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time';
@@ -48,6 +49,11 @@
let lastClickedPath = $state<string | null>(null);
let batchMovePicker = $state(false);
// Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
function clearSelection() {
selectedPaths = new Set();
lastClickedPath = null;
@@ -260,12 +266,33 @@
await removeQuickAccess(note.relative_path);
const qaNotes = await getQuickAccess();
$quickAccessPaths = qaNotes.map(n => n.relative_path);
if ($viewMode === 'quickaccess') await refresh();
if ($viewMode === 'quickaccess') await refresh(true);
} catch (e) {
console.error('Failed to remove from quick access:', e);
}
}
async function handleQaDrop(targetIndex: number) {
if (qaDragFrom === null) { qaDragFrom = null; qaDragOver = null; return; }
// Compute the actual insert position based on which half we're hovering
let insertAt = qaDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
if (qaDragFrom === insertAt || qaDragFrom + 1 === insertAt) {
qaDragFrom = null; qaDragOver = null; return;
}
const arr = [...$sortedNotes];
const [moved] = arr.splice(qaDragFrom, 1);
if (insertAt > qaDragFrom) insertAt--;
arr.splice(insertAt, 0, moved);
$notes = arr;
qaDragFrom = null;
qaDragOver = null;
try {
await reorderQuickAccess(arr.map(n => n.relative_path));
} catch (e) {
console.error('Failed to reorder quick access:', e);
}
}
async function handleMoveNote(note: NoteEntry, destPath: string) {
contextMenu = null;
movePickerNote = null;
@@ -491,7 +518,7 @@
</div>
{/if}
{#each $sortedNotes as note (note.path)}
{#each $sortedNotes as note, noteIndex (note.path)}
{#if editingNote === note.path}
<div class="note-item active">
<input
@@ -512,6 +539,8 @@
class:selected={selectedPaths.has(note.path)}
class:pinned={note.meta.pinned}
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'}
onclick={(e) => handleNoteClick(e, note)}
oncontextmenu={(e) => {
e.preventDefault();
@@ -528,6 +557,12 @@
}}
draggable="true"
ondragstart={(e) => {
if ($viewMode === 'quickaccess') {
qaDragFrom = 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 {
@@ -535,6 +570,25 @@
}
e.dataTransfer!.effectAllowed = 'move';
}}
ondragover={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
qaDragOver = noteIndex;
}
}}
ondragleave={() => {
if (qaDragOver === noteIndex) qaDragOver = null;
}}
ondrop={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault();
handleQaDrop(noteIndex);
}
}}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
>
{#if compact}
<div class="note-compact-row">
@@ -761,8 +815,8 @@
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.1s;
margin-bottom: 1px;
contain: content;
}
.note-item:hover {
@@ -1133,4 +1187,12 @@
color: var(--accent);
font-size: 14px;
}
.note-item.qa-drag-above {
border-top: 2px solid var(--accent);
}
.note-item.qa-drag-below {
border-bottom: 2px solid var(--accent);
}
</style>
+14 -2
View File
@@ -2,23 +2,35 @@
let { onResize }: { onResize: (delta: number) => void } = $props();
let active = $state(false);
let startX = 0;
let rafId = 0;
let pendingDelta = 0;
function onMouseDown(e: MouseEvent) {
e.preventDefault();
active = true;
startX = e.clientX;
document.body.classList.add('resizing');
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e: MouseEvent) {
const delta = e.clientX - startX;
pendingDelta += e.clientX - startX;
startX = e.clientX;
onResize(delta);
if (!rafId) {
rafId = requestAnimationFrame(() => {
onResize(pendingDelta);
pendingDelta = 0;
rafId = 0;
});
}
}
function onMouseUp() {
active = false;
document.body.classList.remove('resizing');
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
if (pendingDelta) { onResize(pendingDelta); pendingDelta = 0; }
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
+50 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { showSettings, theme, appConfig } from '$lib/stores/app';
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, 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';
@@ -24,6 +24,23 @@
}
loadAppVersion();
// Switch to requested tab if set externally (e.g. from update badge)
$effect(() => {
const tab = $settingsTab;
if (tab) {
activeTab = tab as Tab;
$settingsTab = null;
}
});
// Pre-populate from global store if update was already detected at startup
$effect(() => {
const global = $globalUpdateAvailable;
if (global && !updateAvailable) {
updateAvailable = { version: global.version, body: global.body };
}
});
async function handleCheckUpdate() {
updateChecking = true;
updateMessage = null;
@@ -33,6 +50,7 @@
if (update) {
updateObj = 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!` };
} else {
updateMessage = { type: 'success', text: 'You are on the latest version.' };
@@ -1034,6 +1052,7 @@
<div class="update-notes">{updateAvailable.body}</div>
{/if}
</div>
{#if $installType === 'appimage' || $installType === 'windows'}
<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>
@@ -1050,6 +1069,19 @@
<div class="update-progress-fill" style="width: {updateProgress}%"></div>
</div>
{/if}
{:else if $installType === 'deb'}
<div class="update-apt-info">
<p>Update via your package manager:</p>
<code>sudo apt update && sudo apt upgrade helix-notes</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">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Download from Codeberg
</a>
{/if}
</div>
{/if}
@@ -1918,6 +1950,23 @@
cursor: not-allowed;
}
.update-apt-info {
margin-top: 8px;
}
.update-apt-info p {
margin: 0 0 6px 0;
font-size: 13px;
color: var(--text-secondary);
}
.update-apt-info code {
display: block;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 6px;
font-size: 12px;
user-select: all;
}
.update-progress-bar {
margin-top: 10px;
height: 6px;
+26 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode } from '$lib/stores/app';
import { vaultReady, focusMode, updateAvailable, showSettings, settingsTab } from '$lib/stores/app';
let { onNewNote = () => {} }: {
onNewNote?: () => void;
@@ -71,6 +71,12 @@
<line x1="29" y1="18" x2="19" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.7" />
</svg>
<span class="titlebar-title">HelixNotes</span>
{#if $updateAvailable}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button class="update-badge" onmousedown={(e) => e.stopPropagation()} onclick={() => { $settingsTab = 'updates'; $showSettings = true; }}>
v{$updateAvailable.version} available
</button>
{/if}
</div>
<div class="titlebar-actions">
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
@@ -144,6 +150,25 @@
color: var(--text-tertiary);
}
.update-badge {
font-size: 10px;
font-weight: 600;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-radius: 10px;
padding: 1px 8px;
cursor: pointer;
pointer-events: auto;
-webkit-app-region: no-drag;
transition: background 0.15s, border-color 0.15s;
}
.update-badge:hover {
background: color-mix(in srgb, var(--accent) 20%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.titlebar-actions {
display: flex;
align-items: center;
+48 -22
View File
@@ -23,6 +23,7 @@ export const searchQuery = writable("");
export const showCommandPalette = writable(false);
export const showSearch = writable(false);
export const showSettings = writable(false);
export const settingsTab = writable<string | null>(null);
export const showInfo = writable(false);
export const notebookIcons = writable<Record<string, string>>({});
export const quickAccessPaths = writable<string[]>([]);
@@ -45,31 +46,56 @@ export const focusMode = writable(false);
// Theme
export const theme = writable<string>("system");
// Derived
export const sortedNotes = derived([notes, sortMode], ([$notes, $sortMode]) => {
const pinned = $notes.filter((n) => n.meta.pinned);
const unpinned = $notes.filter((n) => !n.meta.pinned);
// Update state
export const updateAvailable = writable<{
version: string;
body?: string;
} | null>(null);
export const installType = writable<string>("native");
const sortFn = (a: NoteEntry, b: NoteEntry) => {
switch ($sortMode) {
case "title":
return a.meta.title.localeCompare(b.meta.title);
case "created":
return (
new Date(b.meta.created).getTime() -
new Date(a.meta.created).getTime()
);
case "modified":
default:
return (
new Date(b.meta.modified).getTime() -
new Date(a.meta.modified).getTime()
);
export async function checkForUpdate() {
try {
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (update) {
updateAvailable.set({ version: update.version, body: update.body });
}
};
} catch {
// Silent fail — don't disrupt app startup
}
}
return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)];
});
// Derived
export const sortedNotes = derived(
[notes, sortMode, viewMode],
([$notes, $sortMode, $viewMode]) => {
// Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes;
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 "title":
return a.meta.title.localeCompare(b.meta.title);
case "created":
return (
new Date(b.meta.created).getTime() -
new Date(a.meta.created).getTime()
);
case "modified":
default:
return (
new Date(b.meta.modified).getTime() -
new Date(a.meta.modified).getTime()
);
}
};
return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)];
},
);
export const vaultState = derived(
[
+8 -2
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNotePath } from '$lib/stores/app';
import { theme, appConfig, activeNotePath, installType, checkForUpdate } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile } from '$lib/api';
import { openFile, getInstallType } from '$lib/api';
import { get } from 'svelte/store';
let { children } = $props();
@@ -31,6 +31,12 @@
return resolved.join('/');
}
// Detect install type and check for updates on startup
onMount(() => {
getInstallType().then(t => installType.set(t)).catch(() => {});
checkForUpdate();
});
// Intercept all link clicks in capture phase to prevent webview navigation
onMount(() => {
function handleLinkClick(e: MouseEvent) {