mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-20 18:07:29 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbcca553ce | ||
|
|
6a40f553f1 |
@@ -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.2_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.2/HelixNotes_1.0.2_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
|
||||
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.0.2_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.2/HelixNotes_1.0.2_amd64.deb) | Ubuntu 22.04+ |
|
||||
| Windows | [HelixNotes_1.0.2_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.2/HelixNotes_1.0.2_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
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
Generated
+1
-1
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
description = "Local-first markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -1341,3 +1341,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ pub fn run() {
|
||||
commands::set_ai_settings,
|
||||
commands::test_ai_connection,
|
||||
commands::ai_ask,
|
||||
commands::get_install_type,
|
||||
]);
|
||||
|
||||
if close_to_tray {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.3",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
|
||||
@@ -309,3 +309,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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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}
|
||||
<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 +2531,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 +3348,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 +3377,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 +3766,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +46,25 @@ 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], ([$notes, $sortMode]) => {
|
||||
const pinned = $notes.filter((n) => n.meta.pinned);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user