diff --git a/README.md b/README.md index 647ca06..c42e98f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar - **View mode** — read-only toggle for distraction-free reading - **Local** — everything stays on your machine +Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html) + ## Tech Stack - **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3 diff --git a/package.json b/package.json index df2da33..a1f8981 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.1.2", + "version": "1.1.3", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1141de5..9820819 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.1.2" +version = "1.1.3" dependencies = [ "chrono", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index add1919..d1187ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.1.2" +version = "1.1.3" description = "Local markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 61a7ac4..f3b8225 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.1.2", + "version": "1.1.3", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index d0cc2ad..8f48bfd 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -170,6 +170,10 @@ e.preventDefault(); editor?.forceSave(); } + if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) { + e.preventDefault(); + editor?.addLinkFromToolbar(); + } if (mod && e.shiftKey && e.key === 'M') { e.preventDefault(); $sourceMode = !$sourceMode; diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index d9f8f43..38d718a 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -52,6 +52,9 @@ let editor: Editor | null = null; let editorReady = $state(false); let sourceContent = $state(''); + let sourceHistory: Array<{ content: string; cursor: number }> = []; + let sourceHistoryIndex = -1; + let sourceHistoryTimer: ReturnType | null = null; let loadedPath = ''; let pendingContent = $state(null); let ignoreNextUpdate = false; @@ -181,6 +184,8 @@ let linkModal = $state(false); let linkModalUrl = $state(''); let linkModalInput = $state(null!); + let linkSelectionFrom = 0; + let linkSelectionTo = 0; let textContextMenu = $state<{ x: number; y: number } | null>(null); let tableContextMenu = $state<{ x: number; y: number } | null>(null); let tablePickerOpen = $state(false); @@ -1195,6 +1200,7 @@ if (editor) editor.setEditable(!shouldBeReadOnly); if ($sourceMode) { sourceContent = stripTitleH1(content); + resetSourceHistory(sourceContent); isLoadingNote = false; } else if (editorElement && editor) { // Editor already exists, just swap content @@ -1470,7 +1476,7 @@ } else if (child.type.name === 'mathInline') { parts.push(`$${child.attrs.tex || ''}$`); } else if (child.type.name === 'hardBreak') { - parts.push('\n'); + parts.push(' \n'); } }); return parts.join(''); @@ -1548,6 +1554,69 @@ sourceElement.scrollTop = Math.max(0, targetScroll); } + function resetSourceHistory(content: string) { + sourceHistory = [{ content, cursor: content.length }]; + sourceHistoryIndex = 0; + if (sourceHistoryTimer) { + clearTimeout(sourceHistoryTimer); + sourceHistoryTimer = null; + } + } + + function pushSourceHistoryImmediate() { + if (!sourceElement) return; + if (sourceHistoryTimer) { + clearTimeout(sourceHistoryTimer); + sourceHistoryTimer = null; + } + const entry = { content: sourceContent, cursor: sourceElement.selectionStart }; + // Don't push duplicate + if (sourceHistoryIndex >= 0 && sourceHistory[sourceHistoryIndex]?.content === entry.content) return; + // Truncate any redo history + sourceHistory = sourceHistory.slice(0, sourceHistoryIndex + 1); + sourceHistory.push(entry); + sourceHistoryIndex++; + // Limit stack size + if (sourceHistory.length > 200) { + sourceHistory.shift(); + sourceHistoryIndex--; + } + } + + function pushSourceHistoryDebounced() { + if (sourceHistoryTimer) clearTimeout(sourceHistoryTimer); + sourceHistoryTimer = setTimeout(() => { + sourceHistoryTimer = null; + pushSourceHistoryImmediate(); + }, 300); + } + + function sourceUndo() { + // Flush any pending debounced snapshot first + if (sourceHistoryTimer) { + clearTimeout(sourceHistoryTimer); + sourceHistoryTimer = null; + pushSourceHistoryImmediate(); + } + if (sourceHistoryIndex <= 0) return; + sourceHistoryIndex--; + const entry = sourceHistory[sourceHistoryIndex]; + sourceContent = entry.content; + tick().then(() => { + sourceElement?.setSelectionRange(entry.cursor, entry.cursor); + }); + } + + function sourceRedo() { + if (sourceHistoryIndex >= sourceHistory.length - 1) return; + sourceHistoryIndex++; + const entry = sourceHistory[sourceHistoryIndex]; + sourceContent = entry.content; + tick().then(() => { + sourceElement?.setSelectionRange(entry.cursor, entry.cursor); + }); + } + function applySearchDecorations() { if (!editor) return; const decorations = noteSearchResults.map((m, i) => @@ -1989,6 +2058,14 @@ } } }, + // Prevent native text drag — it causes copy-instead-of-move in Tauri's webview. + // File drops from OS are handled by Tauri's onDragDropEvent listener instead. + dragstart: (_view, event) => { + const dt = event.dataTransfer; + if (!dt || dt.files.length === 0) { + event.preventDefault(); + } + }, }, handleDrop: (_view, event) => handleFileDrop(event), handlePaste: (_view, event) => handleFilePaste(event), @@ -2023,8 +2100,11 @@ } } - function addLinkFromToolbar() { + export function addLinkFromToolbar() { if (!editor) return; + const { from, to } = editor.state.selection; + linkSelectionFrom = from; + linkSelectionTo = to; const previousUrl = editor.getAttributes('link').href || ''; linkModalUrl = previousUrl; linkModal = true; @@ -2033,11 +2113,14 @@ function linkModalConfirm() { if (!editor) return; - const url = linkModalUrl.trim(); + let url = linkModalUrl.trim(); if (url === '') { - editor.chain().focus().extendMarkRange('link').unsetLink().run(); + editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run(); } else { - editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#')) { + url = 'https://' + url; + } + editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href: url }).run(); } linkModal = false; linkModalUrl = ''; @@ -2743,6 +2826,7 @@ if (isSource && !lastSourceMode) { // Switching TO source: extract markdown from editor sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? ''); + resetSourceHistory(sourceContent); lastSourceMode = true; } else if (!isSource && lastSourceMode) { // Switching BACK to WYSIWYG: destroy old editor (its DOM element is gone), @@ -3016,10 +3100,12 @@
{#if $sourceMode} {#if $appConfig?.show_line_numbers} -