From 4d9369602804ad9cda24f0affa86534731f9f163 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Sat, 28 Feb 2026 23:20:19 +0100 Subject: [PATCH] v1.2.1 - Internal note links, mobile improvements, window state persistence --- fdroid/com.helixnotes.app.yml | 174 ++++++++++++++++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 20 +++- src-tauri/Cargo.toml | 3 +- src-tauri/capabilities/desktop.json | 3 +- src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/lib/components/AppLayout.svelte | 82 +++++++++---- src/lib/components/Editor.svelte | 172 +++++++++++++++++++++++++-- src/lib/components/NoteList.svelte | 128 +++++++++++++++++++- src/routes/+layout.svelte | 88 ++++++++++---- 11 files changed, 611 insertions(+), 64 deletions(-) create mode 100644 fdroid/com.helixnotes.app.yml diff --git a/fdroid/com.helixnotes.app.yml b/fdroid/com.helixnotes.app.yml new file mode 100644 index 0000000..95dc1ac --- /dev/null +++ b/fdroid/com.helixnotes.app.yml @@ -0,0 +1,174 @@ +Categories: + - Writing + - Office +License: AGPL-3.0-or-later +AuthorName: ArkHost +AuthorWebSite: https://helixnotes.com +SourceCode: https://codeberg.org/ArkHost/HelixNotes +IssueTracker: https://codeberg.org/ArkHost/HelixNotes/issues +Changelog: https://codeberg.org/ArkHost/HelixNotes/releases + +AutoName: HelixNotes + +RepoType: git +Repo: https://codeberg.org/ArkHost/HelixNotes.git + +Builds: + - versionName: 1.2.0 + versionCode: 10200001 + commit: v1.2.0 + timeout: 10800 + sudo: + - apt-get update + - apt-get install -y make pkg-config libssl-dev + output: src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release-unsigned.apk + srclibs: + - rustup@1.28.1 + - esbuild@v0.25.0 + prebuild: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - $$rustup$$/rustup-init.sh -y --no-modify-path + - export PATH="$$CARGO_HOME$$/bin:$PATH" + - rustup target add aarch64-linux-android + - cargo install tauri-cli --version ^2.10.0 --locked + - cd $$esbuild$$ + - GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild + - cd $$helixnotes$$ + scandelete: + - src-tauri/gen/android/.gradle + build: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH" + - ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild + - export NDK_HOME="$$NDK$$" + - export ANDROID_HOME="$$SDK$$" + - corepack enable + - corepack prepare pnpm@latest --activate + - pnpm install --frozen-lockfile + - cargo tauri android build --apk --split-per-abi --ci + ndk: 27.0.12077973 + gradle: + - yes + + - versionName: 1.2.0 + versionCode: 10200002 + commit: v1.2.0 + timeout: 10800 + sudo: + - apt-get update + - apt-get install -y make pkg-config libssl-dev + output: src-tauri/gen/android/app/build/outputs/apk/arm/release/app-arm-release-unsigned.apk + srclibs: + - rustup@1.28.1 + - esbuild@v0.25.0 + prebuild: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - $$rustup$$/rustup-init.sh -y --no-modify-path + - export PATH="$$CARGO_HOME$$/bin:$PATH" + - rustup target add armv7-linux-androideabi + - cargo install tauri-cli --version ^2.10.0 --locked + - cd $$esbuild$$ + - GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild + - cd $$helixnotes$$ + scandelete: + - src-tauri/gen/android/.gradle + build: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH" + - ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild + - export NDK_HOME="$$NDK$$" + - export ANDROID_HOME="$$SDK$$" + - corepack enable + - corepack prepare pnpm@latest --activate + - pnpm install --frozen-lockfile + - cargo tauri android build --apk --split-per-abi --ci + ndk: 27.0.12077973 + gradle: + - yes + + - versionName: 1.2.0 + versionCode: 10200003 + commit: v1.2.0 + timeout: 10800 + sudo: + - apt-get update + - apt-get install -y make pkg-config libssl-dev + output: src-tauri/gen/android/app/build/outputs/apk/x86/release/app-x86-release-unsigned.apk + srclibs: + - rustup@1.28.1 + - esbuild@v0.25.0 + prebuild: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - $$rustup$$/rustup-init.sh -y --no-modify-path + - export PATH="$$CARGO_HOME$$/bin:$PATH" + - rustup target add i686-linux-android + - cargo install tauri-cli --version ^2.10.0 --locked + - cd $$esbuild$$ + - GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild + - cd $$helixnotes$$ + scandelete: + - src-tauri/gen/android/.gradle + build: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH" + - ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild + - export NDK_HOME="$$NDK$$" + - export ANDROID_HOME="$$SDK$$" + - corepack enable + - corepack prepare pnpm@latest --activate + - pnpm install --frozen-lockfile + - cargo tauri android build --apk --split-per-abi --ci + ndk: 27.0.12077973 + gradle: + - yes + + - versionName: 1.2.0 + versionCode: 10200004 + commit: v1.2.0 + timeout: 10800 + sudo: + - apt-get update + - apt-get install -y make pkg-config libssl-dev + output: src-tauri/gen/android/app/build/outputs/apk/x86_64/release/app-x86_64-release-unsigned.apk + srclibs: + - rustup@1.28.1 + - esbuild@v0.25.0 + prebuild: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - $$rustup$$/rustup-init.sh -y --no-modify-path + - export PATH="$$CARGO_HOME$$/bin:$PATH" + - rustup target add x86_64-linux-android + - cargo install tauri-cli --version ^2.10.0 --locked + - cd $$esbuild$$ + - GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild + - cd $$helixnotes$$ + scandelete: + - src-tauri/gen/android/.gradle + build: + - export RUSTUP_HOME="$$rustup$$/rustup" + - export CARGO_HOME="$$rustup$$/cargo" + - export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH" + - ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild + - export NDK_HOME="$$NDK$$" + - export ANDROID_HOME="$$SDK$$" + - corepack enable + - corepack prepare pnpm@latest --activate + - pnpm install --frozen-lockfile + - cargo tauri android build --apk --split-per-abi --ci + ndk: 27.0.12077973 + gradle: + - yes + +AllowedAPKSigningKeys: TODO_YOUR_APK_SIGNING_CERT_SHA256 + +AutoUpdateMode: Version +UpdateCheckMode: Tags ^v[\d.]+$ +CurrentVersion: 1.2.0 +CurrentVersionCode: 10200004 diff --git a/package.json b/package.json index 04f85c8..ed519ac 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d0e4c2e..36e06c2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1849,7 +1849,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.2.0" +version = "1.2.1" dependencies = [ "arboard", "chrono", @@ -1876,6 +1876,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tauri-plugin-window-state", "tokio", "uuid", "walkdir", @@ -3141,7 +3142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -5262,6 +5263,21 @@ dependencies = [ "zip 4.6.1", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 177fc6e..9e2f0aa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.2.0" +version = "1.2.1" description = "Local markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" @@ -41,6 +41,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg [target.'cfg(not(target_os = "android"))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" +tauri-plugin-window-state = "2" arboard = { version = "3", features = ["image-data", "wayland-data-control"] } png = "0.17" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index c18cf3e..78d56da 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -5,6 +5,7 @@ "platforms": ["linux", "macOS", "windows"], "windows": ["main"], "permissions": [ - "updater:default" + "updater:default", + "window-state:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e1e90f..9174e0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -180,6 +180,7 @@ pub fn run() { })); builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + builder = builder.plugin(tauri_plugin_window_state::Builder::default().build()); builder = builder.on_window_event(move |window, event| { match event { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2a98071..d73aee4 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.2.0", + "version": "1.2.1", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index d8564b2..3ec64c6 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -192,34 +192,49 @@ } // Android back gesture / hardware back button support - let mobileNavFromPopstate = false; + // We maintain a simple counter of how many views deep we are. + // sidebar=0, notelist=1, editor=2. Each forward nav pushes, back pops. + let historyDepth = 0; + let navFromPopstate = false; if (isMobile) { - // Seed initial history state - history.replaceState({ mobileView: 'sidebar' }, ''); + history.replaceState({ mobileView: 'sidebar', depth: 0 }, ''); - // When mobileView changes forward, push browser history so Android back gesture works $effect(() => { const view = $mobileView; - if (mobileNavFromPopstate) { - mobileNavFromPopstate = false; + if (navFromPopstate) { + navFromPopstate = false; return; } - // Replace state to track current view - history.pushState({ mobileView: view }, ''); + const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2; + if (targetDepth > historyDepth) { + // Forward navigation — push entries for each level skipped + for (let d = historyDepth + 1; d <= targetDepth; d++) { + const v = d === 1 ? 'notelist' : 'editor'; + history.pushState({ mobileView: v, depth: d }, ''); + } + historyDepth = targetDepth; + } else if (targetDepth < historyDepth) { + // Programmatic back (e.g. mobileBack button) — go back in history + const steps = historyDepth - targetDepth; + historyDepth = targetDepth; + navFromPopstate = true; // suppress the popstate that history.go triggers + history.go(-steps); + } }); window.addEventListener('popstate', (e) => { - const currentView = $mobileView; - if (currentView === 'sidebar') { - // Already at root — let Android handle it (exit app) + if (navFromPopstate) { + navFromPopstate = false; return; } - mobileNavFromPopstate = true; - if (currentView === 'editor') $mobileView = 'notelist'; - else if (currentView === 'notelist') $mobileView = 'sidebar'; - // Push state again so next back gesture also works - history.pushState({ mobileView: $mobileView }, ''); + const state = e.state; + const targetDepth = state?.depth ?? 0; + historyDepth = targetDepth; + navFromPopstate = true; + if (targetDepth === 0) $mobileView = 'sidebar'; + else if (targetDepth === 1) $mobileView = 'notelist'; + else $mobileView = 'editor'; }); } @@ -525,12 +540,7 @@ - -
@@ -831,4 +846,27 @@ pointer-events: auto; } + .mobile-fab { + position: absolute; + bottom: 24px; + right: 20px; + width: 56px; + height: 56px; + border-radius: 16px; + background: var(--accent); + color: white; + border: none; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + cursor: pointer; + z-index: 10; + } + + .mobile-fab:active { + transform: scale(0.93); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index a2f94f3..4dd87d0 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -199,6 +199,13 @@ let linkModalInput = $state(null!); let linkSelectionFrom = 0; let linkSelectionTo = 0; + let linkSuggestIndex = $state(0); + let linkSuggestTitles = $state([]); + let linkSuggestFiltered = $derived.by(() => { + const q = linkModalUrl.trim().toLowerCase(); + if (!q || q.startsWith('http://') || q.startsWith('https://') || q.startsWith('mailto:')) return []; + return linkSuggestTitles.filter(e => e.title.toLowerCase().includes(q)).slice(0, 8); + }); let textContextMenu = $state<{ x: number; y: number } | null>(null); let tableContextMenu = $state<{ x: number; y: number } | null>(null); let tablePickerOpen = $state(false); @@ -1823,7 +1830,12 @@ .replace(/]*>(.*?)<\/p>/gi, '> $1\n') .replace(//gi, '\n> '); }); - md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); + md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, (_m, href, text) => { + // Decode percent-encoded href back to readable form for markdown source + // Spaces are re-encoded by markdownToHtml preprocessing before markdown-it parsing + const decoded = decodeURIComponent(href); + return `[${text}](${decoded})`; + }); md = md.replace(/]*>/gi, (match) => { const srcMatch = match.match(/src="([^"]*)"/); const altMatch = match.match(/alt="([^"]*)"/); @@ -1920,6 +1932,12 @@ return `![${alt}](${url.replace(/ /g, '%20')})`; }); + // Pre-process: percent-encode spaces in link URLs so markdown-it parses them correctly + // Matches [text](url with spaces) but not ![image](url) (already handled above) + src = src.replace(/(? { + return `[${text}](${url.replace(/ /g, '%20')})`; + }); + // Pre-process: transform PDF embed divs — iframes on desktop, clickable links on mobile src = src.replace(/]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => { const vaultRoot = $appConfig?.active_vault ?? ''; @@ -2232,8 +2250,11 @@ linkSelectionFrom = from; linkSelectionTo = to; const previousUrl = editor.getAttributes('link').href || ''; - linkModalUrl = previousUrl; + linkModalUrl = decodeURIComponent(previousUrl); + linkSuggestIndex = 0; linkModal = true; + // Load note titles for autocomplete + getAllNoteTitles().then(t => { linkSuggestTitles = t; }).catch(() => {}); tick().then(() => linkModalInput?.focus()); } @@ -2243,15 +2264,37 @@ if (url === '') { editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run(); } else { - if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#')) { + if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#') && !url.endsWith('.md')) { url = 'https://' + url; } - editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href: url }).run(); + // Store raw URL — encoding is handled during markdown serialization/parsing + const href = url.replace(/[()]/g, (c) => encodeURIComponent(c)); + editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href }).run(); } linkModal = false; linkModalUrl = ''; } + function linkModalSelectNote(entry: NoteTitleEntry) { + // Build a relative .md path from the selected note and confirm immediately + const vaultRoot = $appConfig?.active_vault; + const currentNote = $activeNotePath; + if (vaultRoot && currentNote) { + const noteDir = currentNote.substring(0, currentNote.lastIndexOf('/')); + const targetRel = entry.path.startsWith(vaultRoot) ? entry.path.substring(vaultRoot.length + 1) : entry.path; + const currentRel = noteDir.startsWith(vaultRoot) ? noteDir.substring(vaultRoot.length + 1) : noteDir; + const targetParts = targetRel.split('/'); + const currentParts = currentRel ? currentRel.split('/') : []; + let common = 0; + while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++; + const ups = currentParts.length - common; + linkModalUrl = (ups > 0 ? '../'.repeat(ups) : './') + targetParts.slice(common).join('/'); + } else { + linkModalUrl = entry.title + '.md'; + } + linkModalConfirm(); + } + function linkModalCancel() { linkModal = false; linkModalUrl = ''; @@ -2793,19 +2836,43 @@ linkContextMenu = null; } + /** Resolve a link href to an absolute .md note path, or null if not a note link. */ + function resolveNoteHref(href: string): string | null { + const decoded = decodeURIComponent(href); + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null; + let absPath = decoded; + if (!decoded.startsWith('/')) { + const notePath = $activeNotePath; + if (notePath) { + const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); + absPath = normalizePath(`${noteDir}/${decoded}`); + } else { + const vaultRoot = $appConfig?.active_vault; + if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`); + } + } + return absPath.endsWith('.md') ? absPath : null; + } + function linkMenuOpen() { if (!linkContextMenu) return; const href = linkContextMenu.href; closeLinkContextMenu(); + // Internal .md note link — navigate within the app + const notePath = resolveNoteHref(href); + if (notePath) { + navigateToWikiLink(notePath, ''); + return; + } if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { openUrl(href).catch(console.error); } else { const decoded = decodeURIComponent(href); let absPath = decoded; if (!decoded.startsWith('/')) { - const notePath = $activeNotePath; - if (notePath) { - const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); + const np = $activeNotePath; + if (np) { + const noteDir = np.substring(0, np.lastIndexOf('/')); absPath = normalizePath(`${noteDir}/${decoded}`); } else { const vaultRoot = $appConfig?.active_vault; @@ -2832,7 +2899,7 @@ if (pos >= 0) { editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run(); } - linkModalUrl = href; + linkModalUrl = decodeURIComponent(href); linkModal = true; tick().then(() => linkModalInput?.focus()); } @@ -4574,12 +4641,34 @@ class="link-modal-input" bind:this={linkModalInput} bind:value={linkModalUrl} + oninput={() => { linkSuggestIndex = 0; }} onkeydown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); } + if (linkSuggestFiltered.length > 0) { + if (e.key === 'ArrowDown') { e.preventDefault(); linkSuggestIndex = Math.min(linkSuggestIndex + 1, linkSuggestFiltered.length - 1); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); linkSuggestIndex = Math.max(linkSuggestIndex - 1, 0); return; } + if (e.key === 'Enter') { e.preventDefault(); linkModalSelectNote(linkSuggestFiltered[linkSuggestIndex]); return; } + } else { + if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); } + } if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); } }} - placeholder="https://example.com" + placeholder="URL or note name" /> + {#if linkSuggestFiltered.length > 0} + + {/if}