mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
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:
@@ -62,6 +62,8 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
|
|||||||
- **View mode** — read-only toggle for distraction-free reading
|
- **View mode** — read-only toggle for distraction-free reading
|
||||||
- **Local** — everything stays on your machine
|
- **Local** — everything stays on your machine
|
||||||
|
|
||||||
|
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
|
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.1.2"
|
version = "1.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.1.2"
|
version = "1.1.3"
|
||||||
description = "Local markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
|
|||||||
@@ -170,6 +170,10 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.forceSave();
|
editor?.forceSave();
|
||||||
}
|
}
|
||||||
|
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
editor?.addLinkFromToolbar();
|
||||||
|
}
|
||||||
if (mod && e.shiftKey && e.key === 'M') {
|
if (mod && e.shiftKey && e.key === 'M') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$sourceMode = !$sourceMode;
|
$sourceMode = !$sourceMode;
|
||||||
|
|||||||
@@ -52,6 +52,9 @@
|
|||||||
let editor: Editor | null = null;
|
let editor: Editor | null = null;
|
||||||
let editorReady = $state(false);
|
let editorReady = $state(false);
|
||||||
let sourceContent = $state('');
|
let sourceContent = $state('');
|
||||||
|
let sourceHistory: Array<{ content: string; cursor: number }> = [];
|
||||||
|
let sourceHistoryIndex = -1;
|
||||||
|
let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let loadedPath = '';
|
let loadedPath = '';
|
||||||
let pendingContent = $state<string | null>(null);
|
let pendingContent = $state<string | null>(null);
|
||||||
let ignoreNextUpdate = false;
|
let ignoreNextUpdate = false;
|
||||||
@@ -181,6 +184,8 @@
|
|||||||
let linkModal = $state(false);
|
let linkModal = $state(false);
|
||||||
let linkModalUrl = $state('');
|
let linkModalUrl = $state('');
|
||||||
let linkModalInput = $state<HTMLInputElement>(null!);
|
let linkModalInput = $state<HTMLInputElement>(null!);
|
||||||
|
let linkSelectionFrom = 0;
|
||||||
|
let linkSelectionTo = 0;
|
||||||
let textContextMenu = $state<{ x: number; y: number } | null>(null);
|
let textContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tableContextMenu = $state<{ x: number; y: number } | null>(null);
|
let tableContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tablePickerOpen = $state(false);
|
let tablePickerOpen = $state(false);
|
||||||
@@ -1195,6 +1200,7 @@
|
|||||||
if (editor) editor.setEditable(!shouldBeReadOnly);
|
if (editor) editor.setEditable(!shouldBeReadOnly);
|
||||||
if ($sourceMode) {
|
if ($sourceMode) {
|
||||||
sourceContent = stripTitleH1(content);
|
sourceContent = stripTitleH1(content);
|
||||||
|
resetSourceHistory(sourceContent);
|
||||||
isLoadingNote = false;
|
isLoadingNote = false;
|
||||||
} else if (editorElement && editor) {
|
} else if (editorElement && editor) {
|
||||||
// Editor already exists, just swap content
|
// Editor already exists, just swap content
|
||||||
@@ -1470,7 +1476,7 @@
|
|||||||
} else if (child.type.name === 'mathInline') {
|
} else if (child.type.name === 'mathInline') {
|
||||||
parts.push(`$${child.attrs.tex || ''}$`);
|
parts.push(`$${child.attrs.tex || ''}$`);
|
||||||
} else if (child.type.name === 'hardBreak') {
|
} else if (child.type.name === 'hardBreak') {
|
||||||
parts.push('\n');
|
parts.push(' \n');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return parts.join('');
|
return parts.join('');
|
||||||
@@ -1548,6 +1554,69 @@
|
|||||||
sourceElement.scrollTop = Math.max(0, targetScroll);
|
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() {
|
function applySearchDecorations() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const decorations = noteSearchResults.map((m, i) =>
|
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),
|
handleDrop: (_view, event) => handleFileDrop(event),
|
||||||
handlePaste: (_view, event) => handleFilePaste(event),
|
handlePaste: (_view, event) => handleFilePaste(event),
|
||||||
@@ -2023,8 +2100,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLinkFromToolbar() {
|
export function addLinkFromToolbar() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
linkSelectionFrom = from;
|
||||||
|
linkSelectionTo = to;
|
||||||
const previousUrl = editor.getAttributes('link').href || '';
|
const previousUrl = editor.getAttributes('link').href || '';
|
||||||
linkModalUrl = previousUrl;
|
linkModalUrl = previousUrl;
|
||||||
linkModal = true;
|
linkModal = true;
|
||||||
@@ -2033,11 +2113,14 @@
|
|||||||
|
|
||||||
function linkModalConfirm() {
|
function linkModalConfirm() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const url = linkModalUrl.trim();
|
let url = linkModalUrl.trim();
|
||||||
if (url === '') {
|
if (url === '') {
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run();
|
||||||
} else {
|
} 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;
|
linkModal = false;
|
||||||
linkModalUrl = '';
|
linkModalUrl = '';
|
||||||
@@ -2743,6 +2826,7 @@
|
|||||||
if (isSource && !lastSourceMode) {
|
if (isSource && !lastSourceMode) {
|
||||||
// Switching TO source: extract markdown from editor
|
// Switching TO source: extract markdown from editor
|
||||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
||||||
|
resetSourceHistory(sourceContent);
|
||||||
lastSourceMode = true;
|
lastSourceMode = true;
|
||||||
} else if (!isSource && lastSourceMode) {
|
} else if (!isSource && lastSourceMode) {
|
||||||
// Switching BACK to WYSIWYG: destroy old editor (its DOM element is gone),
|
// Switching BACK to WYSIWYG: destroy old editor (its DOM element is gone),
|
||||||
@@ -3016,10 +3100,12 @@
|
|||||||
<div class="editor-body">
|
<div class="editor-body">
|
||||||
{#if $sourceMode}
|
{#if $sourceMode}
|
||||||
{#if $appConfig?.show_line_numbers}
|
{#if $appConfig?.show_line_numbers}
|
||||||
<div class="line-numbers" aria-hidden="true">
|
<div class="line-numbers-clip" aria-hidden="true">
|
||||||
{#each sourceContent.split('\n') as _, i}
|
<div class="line-numbers">
|
||||||
<span>{i + 1}</span>
|
{#each sourceContent.split('\n') as _, i}
|
||||||
{/each}
|
<span>{i + 1}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<textarea
|
<textarea
|
||||||
@@ -3032,10 +3118,42 @@
|
|||||||
oninput={() => {
|
oninput={() => {
|
||||||
$editorDirty = true;
|
$editorDirty = true;
|
||||||
autoSave();
|
autoSave();
|
||||||
|
pushSourceHistoryDebounced();
|
||||||
}}
|
}}
|
||||||
onkeydown={(e) => {
|
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')) {
|
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
const ta = sourceElement;
|
const ta = sourceElement;
|
||||||
const val = ta.value;
|
const val = ta.value;
|
||||||
const start = ta.selectionStart;
|
const start = ta.selectionStart;
|
||||||
@@ -3055,6 +3173,7 @@
|
|||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
const newPos = lines.slice(0, curLine - 1).join('\n').length + 1 + (start - pos);
|
const newPos = lines.slice(0, curLine - 1).join('\n').length + 1 + (start - pos);
|
||||||
ta.setSelectionRange(newPos, newPos);
|
ta.setSelectionRange(newPos, newPos);
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
});
|
});
|
||||||
} else if (e.key === 'ArrowDown' && curLine < lines.length - 1) {
|
} else if (e.key === 'ArrowDown' && curLine < lines.length - 1) {
|
||||||
const tmp = lines[curLine];
|
const tmp = lines[curLine];
|
||||||
@@ -3064,6 +3183,7 @@
|
|||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
const newPos = lines.slice(0, curLine + 1).join('\n').length + 1 + (start - pos);
|
const newPos = lines.slice(0, curLine + 1).join('\n').length + 1 + (start - pos);
|
||||||
ta.setSelectionRange(newPos, newPos);
|
ta.setSelectionRange(newPos, newPos);
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$editorDirty = true;
|
$editorDirty = true;
|
||||||
@@ -3072,8 +3192,11 @@
|
|||||||
}}
|
}}
|
||||||
onscroll={() => {
|
onscroll={() => {
|
||||||
if ($appConfig?.show_line_numbers) {
|
if ($appConfig?.show_line_numbers) {
|
||||||
const gutter = sourceElement?.previousElementSibling as HTMLElement;
|
const clip = sourceElement?.previousElementSibling as HTMLElement;
|
||||||
if (gutter) gutter.scrollTop = sourceElement.scrollTop;
|
const gutter = clip?.firstElementChild as HTMLElement;
|
||||||
|
if (gutter) {
|
||||||
|
gutter.style.transform = `translateY(-${sourceElement.scrollTop}px)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
@@ -3985,8 +4108,8 @@
|
|||||||
bind:this={linkModalInput}
|
bind:this={linkModalInput}
|
||||||
bind:value={linkModalUrl}
|
bind:value={linkModalUrl}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') linkModalConfirm();
|
if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); }
|
||||||
if (e.key === 'Escape') linkModalCancel();
|
if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); }
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
/>
|
/>
|
||||||
@@ -4573,23 +4696,28 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-editor.with-line-numbers {
|
.source-editor.with-line-numbers {
|
||||||
padding-left: 48px;
|
padding-left: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-numbers {
|
.line-numbers-clip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
pointer-events: none;
|
||||||
flex-direction: column;
|
}
|
||||||
padding-top: 0;
|
|
||||||
|
.line-numbers {
|
||||||
|
padding-top: 8px;
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
font-size: var(--editor-font-size, 14px);
|
font-size: var(--editor-font-size, 14px);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -4597,10 +4725,11 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: none;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-numbers span {
|
.line-numbers span {
|
||||||
|
display: block;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user