diff --git a/package.json b/package.json index 1a7f250..541878f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.0.2", + "version": "1.0.3", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fd9dee4..96647bf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.0.2" +version = "1.0.3" dependencies = [ "chrono", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fd3a7da..9df50de 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.0.2" +version = "1.0.3" description = "Local-first markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6e68341..0b00e29 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1350,6 +1350,8 @@ pub fn get_install_type() -> String { "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() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5bb5154..4f59327 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "HelixNotes", - "version": "1.0.2", + "version": "1.0.3", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index aa9af0d..0e2c430 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -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 @@ }); - +
{#if $focusMode} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 9fdc561..44b1676 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -93,6 +93,14 @@ let historySelected = $state(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(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,82 @@ return parts.join(''); } + function autofocus(el: HTMLElement) { + requestAnimationFrame(() => el.focus()); + } + + // ── In-note search functions ── + function updateNoteSearch(query: string) { + if (!editor) return; + if (!query) { + noteSearchResults = []; + noteSearchIndex = 0; + const tr = editor.state.tr.setMeta(noteSearchPluginKey, DecorationSet.empty); + editor.view.dispatch(tr); + 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(); + } + + 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; + const match = noteSearchResults[noteSearchIndex]; + editor.commands.setTextSelection(match.from); + editor.commands.scrollIntoView(); + } + + 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 +1537,7 @@ DetailsContent, TextAlign.configure({ types: ['heading', 'paragraph'] }), SlashCommands, + NoteSearchExtension, ...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []), ], content: html, @@ -2294,6 +2414,16 @@ {#if readOnly} View Mode {/if} + + + +
+ {/if}
{#if $sourceMode}
{/if} + {:else if $installType === 'deb'} +
+

Update via your package manager:

+ sudo apt update && sudo apt upgrade helix-notes +
{:else} @@ -1945,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;