Compare commits

..
1 Commits
Author SHA1 Message Date
Yuri Karamian 46f4b6e7bc v1.0.0 - Initial release 2026-02-09 01:47:56 +01:00
20 changed files with 124 additions and 819 deletions
+6 -7
View File
@@ -6,12 +6,11 @@ Your notes are stored as standard Markdown files on your local filesystem. No cl
## Download
| 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 | |
| Platform | Download |
|----------|----------|
| Linux | [HelixNotes_1.0.0_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.0/HelixNotes_1.0.0_amd64.AppImage) |
| Windows | Coming soon |
| macOS | Coming soon |
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
@@ -34,7 +33,7 @@ See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.or
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
- **Platforms**: Linux (AppImage), Windows, macOS
- **Platforms**: Linux (AppImage, .deb, .rpm), Windows, macOS
## Building from Source
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.0.4",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.0.4"
version = "1.0.0"
dependencies = [
"chrono",
"dirs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.0.4"
version = "1.0.0"
description = "Local-first markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
+2 -58
View File
@@ -2,7 +2,6 @@ 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 ──
@@ -196,39 +195,8 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin
}
#[tauri::command]
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)
pub fn move_note(note_path: String, dest_notebook: String) -> Result<String, String> {
operations::move_note(&note_path, &dest_notebook)
}
// ── Tags ──
@@ -423,7 +391,6 @@ pub fn set_general_settings(
hide_title_in_body: bool,
default_view_mode: bool,
show_tray_icon: bool,
close_to_tray: bool,
enable_wiki_links: bool,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
@@ -437,7 +404,6 @@ pub fn set_general_settings(
config.hide_title_in_body = hide_title_in_body;
config.default_view_mode = default_view_mode;
config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray;
config.enable_wiki_links = enable_wiki_links;
save_app_config(&config)?;
Ok(())
@@ -469,13 +435,6 @@ 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())?;
@@ -1380,18 +1339,3 @@ 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 -16
View File
@@ -19,10 +19,9 @@ use tauri::{
pub fn run() {
let config = commands::load_app_config();
let show_tray = config.show_tray_icon;
let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config);
let mut builder = tauri::Builder::default()
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
@@ -79,7 +78,6 @@ 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,
@@ -95,19 +93,7 @@ pub fn run() {
commands::set_ai_settings,
commands::test_ai_connection,
commands::ai_ask,
commands::get_install_type,
]);
if close_to_tray {
builder = builder.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
});
}
builder
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-3
View File
@@ -97,8 +97,6 @@ pub struct AppConfig {
pub default_view_mode: bool,
#[serde(default)]
pub show_tray_icon: bool,
#[serde(default)]
pub close_to_tray: bool,
#[serde(default = "default_true")]
pub enable_wiki_links: bool,
}
@@ -162,7 +160,6 @@ impl Default for AppConfig {
ai_writing_style: None,
default_view_mode: false,
show_tray_icon: false,
close_to_tray: false,
enable_wiki_links: true,
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.0.4",
"version": "1.0.0",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+1 -10
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: 3px;
--panel-resize-handle: 4px;
}
:root.dark {
@@ -149,12 +149,3 @@ body {
.resize-handle.active {
background: var(--accent);
}
body.resizing .ProseMirror {
pointer-events: none;
contain: strict;
}
body.resizing {
user-select: none;
}
-10
View File
@@ -182,7 +182,6 @@ export async function setGeneralSettings(
hideTitleInBody: boolean,
defaultViewMode: boolean,
showTrayIcon: boolean,
closeToTray: boolean,
enableWikiLinks: boolean,
): Promise<void> {
return invoke("set_general_settings", {
@@ -196,7 +195,6 @@ export async function setGeneralSettings(
hideTitleInBody,
defaultViewMode,
showTrayIcon,
closeToTray,
enableWikiLinks,
});
}
@@ -213,10 +211,6 @@ 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");
}
@@ -313,7 +307,3 @@ 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");
}
+5 -69
View File
@@ -20,15 +20,11 @@
showCommandPalette,
theme,
focusMode,
activeNote,
activeNotePath,
editorDirty,
showInfo,
showSettings
activeNote
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
import { loadVaultState, saveVaultState, readNote } from '$lib/api';
import { loadVaultState, saveVaultState } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import type { VaultState, FileEvent } from '$lib/types';
@@ -36,39 +32,6 @@
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 = {
@@ -106,22 +69,7 @@
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();
@@ -129,19 +77,9 @@
if (e.ctrlKey && e.shiftKey && e.key === 'N') {
e.preventDefault();
}
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
if (e.ctrlKey && 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();
@@ -152,9 +90,7 @@
editor?.forceSave();
}
if (e.key === 'Escape') {
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
else if ($focusMode) $focusMode = false;
if ($focusMode) $focusMode = false;
else if ($showSearch) $showSearch = false;
else if ($showCommandPalette) $showCommandPalette = false;
}
@@ -206,7 +142,7 @@
});
</script>
<svelte:window onkeydown={handleKeydown} onmousedown={handleMouseDown} />
<svelte:window onkeydown={handleKeydown} />
<div class="app-shell">
{#if $focusMode}
+15 -248
View File
@@ -93,14 +93,6 @@
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);
@@ -275,31 +267,6 @@
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() {
@@ -430,14 +397,7 @@
closeSlashMenu();
return true;
}
if (event.key === 'Tab') {
event.preventDefault();
if (slashTableHover.rows > 0 && slashTableHover.cols > 0) {
slashInsertTable(slashTableHover.rows, slashTableHover.cols);
}
return true;
}
if (event.key === 'ArrowRight') {
if (event.key === 'ArrowRight' || event.key === 'Tab') {
event.preventDefault();
slashTableHover = { rows: Math.max(1, slashTableHover.rows), cols: Math.min(10, (slashTableHover.cols || 0) + 1) };
return true;
@@ -476,7 +436,7 @@
slashSelectedIndex = (slashSelectedIndex - 1 + slashFiltered.length) % Math.max(1, slashFiltered.length);
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
if (event.key === 'Enter') {
if (slashFiltered.length > 0) {
event.preventDefault();
executeSlashCommand(slashSelectedIndex);
@@ -1078,12 +1038,9 @@
return '```' + lang + '\n' + code + '\n```\n';
}
case 'blockquote': {
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';
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';
}
case 'bulletList': {
const items: string[] = [];
@@ -1187,91 +1144,6 @@
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 '';
@@ -1546,7 +1418,6 @@
DetailsContent,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
SlashCommands,
NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
],
content: html,
@@ -2004,17 +1875,8 @@
}
}
async function aiApplyResult() {
function aiApplyResult() {
if (!editor || !aiResult) return;
// Save a version snapshot before applying AI changes
if ($activeNotePath && $activeNote && !aiEmptyNote) {
try {
await forceSave();
await createVersion($activeNotePath, $activeNote.meta.id);
} catch (e) {
console.error('Failed to create version before AI apply:', e);
}
}
if (aiEmptyNote) {
// Parse title from first line, rest is content
const lines = aiResult.split('\n');
@@ -2423,16 +2285,6 @@
{#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}
@@ -2540,40 +2392,6 @@
</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
@@ -2719,7 +2537,7 @@
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline (Ctrl+U)">
<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="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough (Ctrl+Shift+X)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough">
<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="M16 4H9a3 3 0 00-3 3 3 3 0 003 3h6"/><line x1="4" y1="12" x2="20" y2="12"/><path d="M8 20h7a3 3 0 003-3 3 3 0 00-3-3H8"/></svg>
</button>
@@ -2746,20 +2564,20 @@
<div class="fmt-sep"></div>
<!-- Link -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link (Ctrl+K)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link">
<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="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Lists -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title="Bullet List (Ctrl+Shift+8)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title="Bullet List">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1.5" fill="currentColor"/><circle cx="3.5" cy="12" r="1.5" fill="currentColor"/><circle cx="3.5" cy="18" r="1.5" fill="currentColor"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Ordered List (Ctrl+Shift+7)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Ordered List">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="8" font-size="8" fill="currentColor" stroke="none" font-weight="600">1</text><text x="1" y="14" font-size="8" fill="currentColor" stroke="none" font-weight="600">2</text><text x="1" y="20" font-size="8" fill="currentColor" stroke="none" font-weight="600">3</text></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title="Task List (Ctrl+Shift+9)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title="Task List">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><polyline points="4.5 6.5 6 8 8.5 4.5"/><line x1="13" y1="6.5" x2="21" y2="6.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><line x1="13" y1="17.5" x2="21" y2="17.5"/></svg>
</button>
@@ -2776,15 +2594,15 @@
<div class="fmt-sep"></div>
<!-- Code & Code Block -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title="Inline Code (Ctrl+E)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title="Inline Code">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title="Code Block (Ctrl+Alt+C)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title="Code Block">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 8 5 12 9 16"/><polyline points="15 8 19 12 15 16"/></svg>
</button>
<!-- Blockquote -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title="Quote (Ctrl+Shift+B)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title="Quote">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 6h4v4l-2 6H3l2-6H3V6zm10 0h4v4l-2 6h-2l2-6h-2V6z"/></svg>
</button>
@@ -2830,7 +2648,7 @@
<!-- Highlight -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Highlight (Ctrl+Shift+H)">
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Highlight">
<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="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/><path d="M15 5l3 3"/></svg>
<span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span>
</button>
@@ -3357,7 +3175,6 @@
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>
@@ -3386,7 +3203,6 @@
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>
@@ -3775,58 +3591,9 @@
.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;
+54 -151
View File
@@ -20,17 +20,16 @@
y: number;
vx: number;
vy: number;
links: string[]; // titles this note links to
}
interface GraphEdge {
sourceIdx: number;
targetIdx: number;
source: string;
target: string;
}
let nodes: GraphNode[] = [];
let edges: GraphEdge[] = [];
let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set();
let animFrame = 0;
let pan = { x: 0, y: 0 };
let zoom = 1;
@@ -40,7 +39,6 @@
let panning = false;
let panStart = { x: 0, y: 0 };
let hoveredNode: GraphNode | null = null;
let glowPhase = 0;
const wikiLinkRegex = /\[\[([^\]]+)\]\]/g;
@@ -56,7 +54,7 @@
// Create nodes
const w = canvas?.width ?? 800;
const h = canvas?.height ?? 600;
nodes = titles.map((t) => ({
nodes = titles.map((t, i) => ({
id: t.title.toLowerCase(),
title: t.title,
path: t.path,
@@ -64,45 +62,33 @@
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0,
vy: 0,
links: [],
}));
// Build index map for O(1) lookups
nodeIndexMap = new Map();
for (let i = 0; i < nodes.length; i++) {
nodeIndexMap.set(nodes[i].id, i);
}
const nodeMap = new Map<string, GraphNode>();
for (const n of nodes) nodeMap.set(n.id, n);
// Read all notes in parallel (batched) to extract [[wiki-links]]
// Read each note to extract [[wiki-links]]
const edgeSet = new Set<string>();
edges = [];
const BATCH_SIZE = 20;
for (let b = 0; b < nodes.length; b += BATCH_SIZE) {
const batch = nodes.slice(b, b + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (node) => {
const content = await readNote(node.path);
return { node, body: content.content || '' };
})
);
for (const result of results) {
if (result.status !== 'fulfilled') continue;
const { node, body } = result.value;
const nodeIdx = nodeIndexMap.get(node.id)!;
for (const node of nodes) {
try {
const content = await readNote(node.path);
const body = content.content || '';
let match;
wikiLinkRegex.lastIndex = 0;
while ((match = wikiLinkRegex.exec(body)) !== null) {
const linkTitle = match[1].trim().toLowerCase();
const targetIdx = nodeIndexMap.get(linkTitle);
if (linkTitle !== node.id && targetIdx !== undefined) {
const edgeKey = nodeIdx < targetIdx ? `${nodeIdx}|${targetIdx}` : `${targetIdx}|${nodeIdx}`;
if (linkTitle !== node.id && nodeMap.has(linkTitle)) {
node.links.push(linkTitle);
const edgeKey = [node.id, linkTitle].sort().join('|');
if (!edgeSet.has(edgeKey)) {
edgeSet.add(edgeKey);
edges.push({ sourceIdx: nodeIdx, targetIdx });
connectedSet.add(nodeIdx);
connectedSet.add(targetIdx);
edges.push({ source: node.id, target: linkTitle });
}
}
}
} catch {
// Skip notes that can't be read
}
}
} catch (e) {
@@ -112,57 +98,13 @@
startSimulation();
}
function centerOnActiveNote() {
if (!canvas || nodes.length === 0) return;
const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath);
// Only center on active note if it has connections
if (!activeNode) return;
const activeIdx = nodeIndexMap.get(activeNode.id);
if (activeIdx === undefined || !connectedSet.has(activeIdx)) return;
// Gather the active node and its direct neighbors
const neighborhood: GraphNode[] = [activeNode];
for (const edge of edges) {
if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]);
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]);
}
const w = canvas.width;
const h = canvas.height;
const padding = 80;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of neighborhood) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
const graphW = maxX - minX || 1;
const graphH = maxY - minY || 1;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
zoom = Math.min(
(w - padding * 2) / graphW,
(h - padding * 2) / graphH,
1.8
);
zoom = Math.max(zoom, 0.5);
pan.x = w / 2 - centerX * zoom;
pan.y = h / 2 - centerY * zoom;
}
function fitToView() {
if (!canvas || nodes.length === 0) return;
const w = canvas.width;
const h = canvas.height;
const padding = 60;
// Compute bounding box of all nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of nodes) {
if (node.x < minX) minX = node.x;
@@ -176,49 +118,36 @@
const centerGraphX = (minX + maxX) / 2;
const centerGraphY = (minY + maxY) / 2;
// Compute zoom to fit
zoom = Math.min(
(w - padding * 2) / graphW,
(h - padding * 2) / graphH,
2
2 // max zoom
);
zoom = Math.max(zoom, 0.2);
// Center the graph
pan.x = w / 2 - centerGraphX * zoom;
pan.y = h / 2 - centerGraphY * zoom;
}
function startSimulation() {
if (animFrame) cancelAnimationFrame(animFrame);
let iterations = 0;
const maxIterations = 300;
// Run physics synchronously — no need to animate the settling
for (let i = 0; i < 300; i++) {
function tick() {
if (iterations >= maxIterations) {
fitToView();
draw();
return;
}
simulate();
}
// Center on active note if it has links, otherwise fit all
const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath);
const activeIdx = activeNode ? nodeIndexMap.get(activeNode.id) : undefined;
if (activeIdx !== undefined && connectedSet.has(activeIdx)) {
centerOnActiveNote();
} else {
fitToView();
}
draw();
startGlowLoop();
}
let glowFrame = 0;
function startGlowLoop() {
if (glowFrame) cancelAnimationFrame(glowFrame);
function loop() {
glowPhase += 0.04;
draw();
glowFrame = requestAnimationFrame(loop);
iterations++;
animFrame = requestAnimationFrame(tick);
}
glowFrame = requestAnimationFrame(loop);
animFrame = requestAnimationFrame(tick);
}
function simulate() {
@@ -232,14 +161,13 @@
// Repulsion between all nodes
for (let i = 0; i < nodeCount; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodeCount; j++) {
const a = nodes[i];
const b = nodes[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const distSq = dx * dx + dy * dy || 1;
const force = 800 / distSq;
const dist = Math.sqrt(distSq);
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = 800 / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx -= fx;
@@ -249,13 +177,14 @@
}
}
// Attraction along edges (indexed lookups)
// Attraction along edges
for (const edge of edges) {
const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const a = nodes.find(n => n.id === edge.source);
const b = nodes.find(n => n.id === edge.target);
if (!a || !b) continue;
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 100) * 0.01;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
@@ -298,14 +227,16 @@
const textColor = style.getPropertyValue('--text-primary').trim() || '#eee';
const textSecondary = style.getPropertyValue('--text-tertiary').trim() || '#888';
const accent = style.getPropertyValue('--accent').trim() || '#7b9bd4';
const accentLight = style.getPropertyValue('--accent-light').trim() || 'rgba(123,155,212,0.15)';
// Draw edges
ctx.strokeStyle = borderColor;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.4;
for (const edge of edges) {
const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx];
const a = nodes.find(n => n.id === edge.source);
const b = nodes.find(n => n.id === edge.target);
if (!a || !b) continue;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
@@ -317,27 +248,11 @@
const activePath = $activeNotePath || '';
// Draw nodes
const pulse = 0.5 + 0.5 * Math.sin(glowPhase);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
for (const node of nodes) {
const isActive = node.path === activePath;
const isHovered = node === hoveredNode;
const hasLinks = connectedSet.has(i);
const baseRadius = isActive ? 9 : hasLinks ? 5 : 3.5;
const radius = isActive ? baseRadius + pulse * 2 : baseRadius;
// Active node glow
if (isActive) {
const glowRadius = radius + 10 + pulse * 6;
const glow = ctx.createRadialGradient(node.x, node.y, radius, node.x, node.y, glowRadius);
glow.addColorStop(0, accent + '60');
glow.addColorStop(1, accent + '00');
ctx.beginPath();
ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
}
const hasLinks = node.links.length > 0 || edges.some(e => e.source === node.id || e.target === node.id);
const radius = isActive ? 7 : hasLinks ? 5 : 3.5;
// Node circle
ctx.beginPath();
@@ -353,23 +268,12 @@
}
ctx.fill();
// Active node ring
if (isActive) {
ctx.beginPath();
ctx.arc(node.x, node.y, radius + 3, 0, Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.4 + pulse * 0.3;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Label
if (isActive || isHovered || hasLinks) {
ctx.font = `${isActive ? 'bold 13' : isHovered ? '12' : '10'}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.font = `${isActive || isHovered ? '12' : '10'}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.fillStyle = isActive || isHovered ? textColor : textSecondary;
ctx.textAlign = 'center';
ctx.fillText(node.title, node.x, node.y - radius - 6);
ctx.fillText(node.title, node.x, node.y - radius - 5);
}
}
@@ -473,7 +377,6 @@
onDestroy(() => {
if (animFrame) cancelAnimationFrame(animFrame);
if (glowFrame) cancelAnimationFrame(glowFrame);
});
</script>
+2 -9
View File
@@ -2,14 +2,10 @@
import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>('shortcuts');
let appVersion = $state('...');
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
function close() {
$showInfo = false;
@@ -70,7 +66,7 @@
</svg>
</div>
<h3 class="app-name">HelixNotes</h3>
<p class="app-version">v{appVersion}</p>
<p class="app-version">v1.0.0</p>
<p class="app-description">A local-first markdown note-taking app.</p>
{#if stats}
@@ -114,16 +110,13 @@
<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">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">Search</span><span class="shortcut-keys"><kbd>Ctrl</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>
+3 -65
View File
@@ -26,7 +26,6 @@
getQuickAccess,
addQuickAccess,
removeQuickAccess,
reorderQuickAccess,
moveNote
} from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time';
@@ -49,11 +48,6 @@
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;
@@ -266,33 +260,12 @@
await removeQuickAccess(note.relative_path);
const qaNotes = await getQuickAccess();
$quickAccessPaths = qaNotes.map(n => n.relative_path);
if ($viewMode === 'quickaccess') await refresh(true);
if ($viewMode === 'quickaccess') await refresh();
} 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;
@@ -518,7 +491,7 @@
</div>
{/if}
{#each $sortedNotes as note, noteIndex (note.path)}
{#each $sortedNotes as note (note.path)}
{#if editingNote === note.path}
<div class="note-item active">
<input
@@ -539,8 +512,6 @@
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();
@@ -557,12 +528,6 @@
}}
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 {
@@ -570,25 +535,6 @@
}
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">
@@ -815,8 +761,8 @@
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.1s;
margin-bottom: 1px;
contain: content;
}
.note-item:hover {
@@ -1187,12 +1133,4 @@
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>
+2 -14
View File
@@ -2,35 +2,23 @@
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) {
pendingDelta += e.clientX - startX;
const delta = e.clientX - startX;
startX = e.clientX;
if (!rafId) {
rafId = requestAnimationFrame(() => {
onResize(pendingDelta);
pendingDelta = 0;
rafId = 0;
});
}
onResize(delta);
}
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);
}
+3 -65
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 } 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,23 +24,6 @@
}
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;
@@ -50,7 +33,6 @@
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.' };
@@ -301,7 +283,6 @@
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
const pdfHeightPresets = [
@@ -353,10 +334,9 @@
$appConfig.hide_title_in_body = hideTitleInBody;
$appConfig.default_view_mode = defaultViewMode;
$appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks;
}
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, defaultViewMode, showTrayIcon, enableWikiLinks)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -580,21 +560,10 @@
<span class="setting-name">Show in system tray</span>
<span class="setting-desc">Show an icon in the notification area (requires restart)</span>
</span>
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
{#if showTrayIcon}
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Close to tray</span>
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
</span>
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
{/if}
</div>
</div>
{:else if activeTab === 'editor'}
@@ -1052,7 +1021,6 @@
<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>
@@ -1069,19 +1037,6 @@
<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}
@@ -1950,23 +1905,6 @@
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;
+1 -34
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode, updateAvailable, showSettings, settingsTab } from '$lib/stores/app';
import { vaultReady, focusMode } from '$lib/stores/app';
let { onNewNote = () => {} }: {
onNewNote?: () => void;
@@ -21,19 +21,11 @@
let lastMouseDown = 0;
const RESIZE_EDGE = 6;
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
// Don't start dragging near window edges — let Tauri handle resize
if (!maximized) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return;
}
const now = Date.now();
if (now - lastMouseDown < 300) {
// Double-click detected — maximize/restore
@@ -71,12 +63,6 @@
<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">
@@ -150,25 +136,6 @@
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;
+22 -48
View File
@@ -23,7 +23,6 @@ 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[]>([]);
@@ -46,56 +45,31 @@ export const focusMode = writable(false);
// Theme
export const theme = writable<string>("system");
// Update state
export const updateAvailable = writable<{
version: string;
body?: string;
} | null>(null);
export const installType = writable<string>("native");
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
}
}
// Derived
export const sortedNotes = derived(
[notes, sortMode, viewMode],
([$notes, $sortMode, $viewMode]) => {
// Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes;
export const sortedNotes = derived([notes, sortMode], ([$notes, $sortMode]) => {
const pinned = $notes.filter((n) => n.meta.pinned);
const unpinned = $notes.filter((n) => !n.meta.pinned);
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()
);
}
};
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)];
},
);
return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)];
});
export const vaultState = derived(
[
+2 -8
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNotePath, installType, checkForUpdate } from '$lib/stores/app';
import { theme, appConfig, activeNotePath } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, getInstallType } from '$lib/api';
import { openFile } from '$lib/api';
import { get } from 'svelte/store';
let { children } = $props();
@@ -31,12 +31,6 @@
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) {