v1.1.3 - Line numbers scroll fix, source mode undo/redo, Ctrl+K links, hard break fixes

- Line numbers scroll fix: source mode line numbers now scroll properly with content
- Source mode undo/redo: custom history stack for Ctrl+Z / Ctrl+Shift+Z in markdown mode
- Shift+Enter hard breaks in source mode (inserts trailing spaces + newline)
- Ctrl+K link shortcut: global keybinding for inserting/editing links
- Link modal improvements: auto-prepends https://, preserves selection range
- Hard break serialization fix: trailing spaces preserved in markdown output
- Drag prevention: prevents native text drag (copy-instead-of-move issue in Tauri webview)
This commit is contained in:
Yuri Karamian
2026-02-18 19:30:02 +01:00
parent 564c28904f
commit c1faf0b0f5
7 changed files with 157 additions and 22 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.1.2"
version = "1.1.3"
dependencies = [
"chrono",
"dirs",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+4
View File
@@ -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;
+147 -18
View File
@@ -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<typeof setTimeout> | null = null;
let loadedPath = '';
let pendingContent = $state<string | null>(null);
let ignoreNextUpdate = false;
@@ -181,6 +184,8 @@
let linkModal = $state(false);
let linkModalUrl = $state('');
let linkModalInput = $state<HTMLInputElement>(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 @@
<div class="editor-body">
{#if $sourceMode}
{#if $appConfig?.show_line_numbers}
<div class="line-numbers" aria-hidden="true">
{#each sourceContent.split('\n') as _, i}
<span>{i + 1}</span>
{/each}
<div class="line-numbers-clip" aria-hidden="true">
<div class="line-numbers">
{#each sourceContent.split('\n') as _, i}
<span>{i + 1}</span>
{/each}
</div>
</div>
{/if}
<textarea
@@ -3032,10 +3118,42 @@
oninput={() => {
$editorDirty = true;
autoSave();
pushSourceHistoryDebounced();
}}
onkeydown={(e) => {
const mod = e.ctrlKey || e.metaKey;
// Shift+Enter: insert two trailing spaces + newline for markdown hard break
if (e.key === 'Enter' && e.shiftKey && !mod) {
e.preventDefault();
const ta = sourceElement;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const val = ta.value;
sourceContent = val.slice(0, start) + ' \n' + val.slice(end);
tick().then(() => {
const newPos = start + 3;
ta.setSelectionRange(newPos, newPos);
});
$editorDirty = true;
autoSave();
pushSourceHistoryDebounced();
return;
}
// Undo
if (mod && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
e.preventDefault();
sourceUndo();
return;
}
// Redo
if ((mod && (e.key === 'z' || e.key === 'Z') && e.shiftKey) || (mod && (e.key === 'y' || e.key === 'Y'))) {
e.preventDefault();
sourceRedo();
return;
}
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
pushSourceHistoryImmediate();
const ta = sourceElement;
const val = ta.value;
const start = ta.selectionStart;
@@ -3055,6 +3173,7 @@
tick().then(() => {
const newPos = lines.slice(0, curLine - 1).join('\n').length + 1 + (start - pos);
ta.setSelectionRange(newPos, newPos);
pushSourceHistoryImmediate();
});
} else if (e.key === 'ArrowDown' && curLine < lines.length - 1) {
const tmp = lines[curLine];
@@ -3064,6 +3183,7 @@
tick().then(() => {
const newPos = lines.slice(0, curLine + 1).join('\n').length + 1 + (start - pos);
ta.setSelectionRange(newPos, newPos);
pushSourceHistoryImmediate();
});
}
$editorDirty = true;
@@ -3072,8 +3192,11 @@
}}
onscroll={() => {
if ($appConfig?.show_line_numbers) {
const gutter = sourceElement?.previousElementSibling as HTMLElement;
if (gutter) gutter.scrollTop = sourceElement.scrollTop;
const clip = sourceElement?.previousElementSibling as HTMLElement;
const gutter = clip?.firstElementChild as HTMLElement;
if (gutter) {
gutter.style.transform = `translateY(-${sourceElement.scrollTop}px)`;
}
}
}}
spellcheck="false"
@@ -3985,8 +4108,8 @@
bind:this={linkModalInput}
bind:value={linkModalUrl}
onkeydown={(e) => {
if (e.key === 'Enter') linkModalConfirm();
if (e.key === 'Escape') linkModalCancel();
if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); }
if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); }
}}
placeholder="https://example.com"
/>
@@ -4573,23 +4696,28 @@
resize: none;
outline: none;
padding: 0;
margin: 0;
user-select: text;
white-space: pre;
overflow-x: auto;
}
.source-editor.with-line-numbers {
padding-left: 48px;
}
.line-numbers {
.line-numbers-clip {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 44px;
overflow: hidden;
display: flex;
flex-direction: column;
padding-top: 0;
pointer-events: none;
}
.line-numbers {
padding-top: 8px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px);
line-height: 1.6;
@@ -4597,10 +4725,11 @@
opacity: 0.5;
text-align: right;
user-select: none;
pointer-events: none;
will-change: transform;
}
.line-numbers span {
display: block;
padding-right: 12px;
}