Compare commits

...
5 Commits
Author SHA1 Message Date
Yuri Karamian c1faf0b0f5 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)
2026-02-18 19:30:02 +01:00
Yuri Karamian 564c28904f Update README: add AUR and APT install instructions, bump to v1.1.2 2026-02-17 00:54:52 +01:00
Yuri Karamian 06d53cea66 v1.1.2 - Alt+Arrow move lines, Ctrl+Shift+M source toggle, code syntax highlighting, outline panel, image spacing fix, daily notes 2026-02-16 23:41:31 +01:00
ivankopylov6603 d9fe358e6b docs: update README (#2)
- Shorten tagline to 75 chars (was 96)
- Replace download table with list for better mobile view
- Absolute URL for LICENSE

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/2
Co-authored-by: ivankopylov6603 <ivankopylov6603@noreply.codeberg.org>
Co-committed-by: ivankopylov6603 <ivankopylov6603@noreply.codeberg.org>
2026-02-16 11:19:21 +01:00
Yuri Karamian 253449acc2 Update README download links to v1.1.1 2026-02-16 02:05:02 +01:00
11 changed files with 601 additions and 32 deletions
+39 -10
View File
@@ -2,24 +2,51 @@
A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. No cloud, no lock-in.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download
| Platform | Download | Notes |
|----------|----------|-------|
| Linux (Arch/rolling) | [HelixNotes_1.1.0_amd64.AppImage](https://download.helixnotes.com/releases/v1.1.0/HelixNotes_1.1.0_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.1.0_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_amd64.deb) | Ubuntu 22.04+ |
| Windows | [HelixNotes_1.1.0_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_x64-setup.exe) | Windows 10/11 |
| macOS | [HelixNotes_1.1.0_x64.dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_x64.dmg) | macOS 12+ (Intel, runs on Apple Silicon via Rosetta) |
### Linux
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
#### Arch / Manjaro (AUR)
```bash
yay -S helixnotes-appimage-bin
```
#### Debian / Ubuntu / Mint (APT)
```bash
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### AppImage (any distro)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.2/HelixNotes_1.1.2_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.2/HelixNotes_1.1.2_amd64.deb) — Ubuntu 22.04+
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.2/HelixNotes_1.1.2_x64-setup.exe) — Windows 10/11
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.2/HelixNotes_1.1.2_x64.dmg) — macOS 12+ (Intel, runs on Apple Silicon via Rosetta)
---
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
## Features
- **Markdown editor** with rich formatting toolbar, slash commands, and source mode
- **Markdown editor** with rich formatting toolbar, slash commands, source mode toggle, and code syntax highlighting
- **Wiki-links** — link notes with `[[Note Title]]` syntax
- **Graph view** — visualize connections between your notes
- **Outline panel** — heading navigation for long notes
- **Full-text search** powered by Tantivy
- **Math support** — KaTeX rendering for inline and block equations
- **Daily notes** — one-click button to create or open today's note
@@ -35,6 +62,8 @@ See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.or
- **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
@@ -65,4 +94,4 @@ pnpm tauri build
## License
[AGPL-3.0](LICENSE)
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.1.1",
"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.1"
version = "1.1.3"
dependencies = [
"chrono",
"dirs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.1.1"
version = "1.1.3"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
+4 -2
View File
@@ -507,6 +507,7 @@ pub fn set_general_settings(
pdf_height: u32,
title_mode: String,
hide_title_in_body: bool,
show_line_numbers: bool,
default_view_mode: bool,
show_tray_icon: bool,
close_to_tray: bool,
@@ -521,6 +522,7 @@ pub fn set_general_settings(
config.pdf_height = pdf_height;
config.title_mode = title_mode;
config.hide_title_in_body = hide_title_in_body;
config.show_line_numbers = show_line_numbers;
config.default_view_mode = default_view_mode;
config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray;
@@ -1478,8 +1480,6 @@ pub fn get_install_type() -> String {
"macos".to_string()
} else 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 if std::path::Path::new("/var/lib/pacman/local").exists()
@@ -1490,6 +1490,8 @@ pub fn get_install_type() -> String {
.unwrap_or(false)
{
"aur".to_string()
} else if std::env::var("APPIMAGE").is_ok() {
"appimage".to_string()
} else {
"native".to_string()
}
+3
View File
@@ -70,6 +70,8 @@ pub struct AppConfig {
#[serde(default)]
pub hide_title_in_body: bool,
#[serde(default)]
pub show_line_numbers: bool,
#[serde(default)]
pub backup_enabled: bool,
#[serde(default = "default_backup_frequency")]
pub backup_frequency: String,
@@ -148,6 +150,7 @@ impl Default for AppConfig {
pdf_height: 600,
title_mode: "input".to_string(),
hide_title_in_body: false,
show_line_numbers: false,
backup_enabled: false,
backup_frequency: "24h".to_string(),
backup_max_count: 10,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.1.1",
"version": "1.1.3",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+2
View File
@@ -191,6 +191,7 @@ export async function setGeneralSettings(
pdfHeight: number,
titleMode: string,
hideTitleInBody: boolean,
showLineNumbers: boolean,
defaultViewMode: boolean,
showTrayIcon: boolean,
closeToTray: boolean,
@@ -205,6 +206,7 @@ export async function setGeneralSettings(
pdfHeight,
titleMode,
hideTitleInBody,
showLineNumbers,
defaultViewMode,
showTrayIcon,
closeToTray,
+10 -1
View File
@@ -25,7 +25,8 @@
activeNotePath,
editorDirty,
showInfo,
showSettings
showSettings,
sourceMode
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
@@ -169,6 +170,14 @@
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;
}
if (e.key === 'Escape') {
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
+527 -14
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;
@@ -99,6 +102,29 @@
// View mode (read-only) — state managed by $readOnly store
// Outline
let showOutline = $state(false);
interface OutlineHeading { level: number; text: string; pos: number; }
let outlineHeadings = $state<OutlineHeading[]>([]);
function updateOutline() {
if (!editor) { outlineHeadings = []; return; }
const headings: OutlineHeading[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
headings.push({ level: node.attrs.level, text: node.textContent, pos });
}
});
outlineHeadings = headings;
}
function scrollToHeading(pos: number) {
if (!editor) return;
editor.commands.setTextSelection(pos + 1);
editor.commands.scrollIntoView();
editor.view.focus();
}
// Version history
let showHistory = $state(false);
let showGraph = $state(false);
@@ -158,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);
@@ -377,6 +405,66 @@
},
});
const MoveLineShortcuts = Extension.create({
name: 'moveLineShortcuts',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('moveLineShortcuts'),
props: {
handleDOMEvents: {
keydown(view, event) {
if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return false;
event.preventDefault();
const { state, dispatch } = view;
const resolvedPos = state.selection.$from;
// Find the top-level block index
const depth = 1; // top-level blocks in doc
if (resolvedPos.depth < depth) return true;
const parentPos = resolvedPos.before(depth);
const parentNode = state.doc.nodeAt(parentPos);
if (!parentNode) return true;
const parentIndex = resolvedPos.index(0);
if (event.key === 'ArrowUp') {
if (parentIndex <= 0) return true;
const prevPos = resolvedPos.posAtIndex(parentIndex - 1, 0);
const prevNode = state.doc.nodeAt(prevPos);
if (!prevNode) return true;
const tr = state.tr;
const cursorOffset = resolvedPos.pos - parentPos;
// Delete current block, insert it before previous block
const curSlice = state.doc.slice(parentPos, parentPos + parentNode.nodeSize);
tr.delete(parentPos, parentPos + parentNode.nodeSize);
const insertAt = tr.mapping.map(prevPos);
tr.insert(insertAt, curSlice.content);
const newCursorPos = Math.min(insertAt + cursorOffset, tr.doc.content.size);
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos)));
dispatch(tr.scrollIntoView());
} else {
if (parentIndex >= state.doc.childCount - 1) return true;
const nextPos = resolvedPos.posAtIndex(parentIndex + 1, 0);
const nextNode = state.doc.nodeAt(nextPos);
if (!nextNode) return true;
const tr = state.tr;
const cursorOffset = resolvedPos.pos - parentPos;
// Delete next block, insert it before current block
const nextSlice = state.doc.slice(nextPos, nextPos + nextNode.nodeSize);
tr.delete(nextPos, nextPos + nextNode.nodeSize);
const insertAt = tr.mapping.map(parentPos);
tr.insert(insertAt, nextSlice.content);
const newCursorPos = Math.min(tr.mapping.map(parentPos) + cursorOffset, tr.doc.content.size);
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos)));
dispatch(tr.scrollIntoView());
}
return true;
},
},
},
}),
];
},
});
const CodeBlockLanguageSelect = Extension.create({
name: 'codeBlockLanguageSelect',
addGlobalAttributes() {
@@ -1112,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
@@ -1177,9 +1266,21 @@
return restoreTitleH1(md);
}
function isImageNode(node: any): boolean {
if (node.type.name === 'image') return true;
if (node.type.name !== 'paragraph') return false;
let hasImage = false;
let hasOther = false;
node.forEach((child: any) => {
if (child.type.name === 'image') hasImage = true;
else hasOther = true;
});
return hasImage && !hasOther;
}
function prosemirrorToMarkdown(doc: any): string {
const listTypes = new Set(['bulletList', 'orderedList', 'taskList']);
const parts: string[] = [];
const entries: { text: string; isImage: boolean }[] = [];
let prevType = '';
let preserveEmptyParas = false;
doc.forEach((node: any) => {
@@ -1190,15 +1291,25 @@
preserveEmptyParas = true;
}
if (isEmpty && preserveEmptyParas) {
parts.push('<!-- -->');
entries.push({ text: '<!-- -->', isImage: false });
prevType = node.type.name;
return;
}
preserveEmptyParas = false;
parts.push(serializeNode(node));
entries.push({ text: serializeNode(node), isImage: isImageNode(node) });
prevType = node.type.name;
});
return parts.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
// Join: skip extra \n separator before image nodes so they don't get unwanted blank lines
let result = '';
for (let i = 0; i < entries.length; i++) {
if (i === 0) {
result = entries[i].text;
} else {
const separator = entries[i].isImage ? '' : '\n';
result += separator + entries[i].text;
}
}
return result.replace(/\n{3,}/g, '\n\n').trim() + '\n';
}
function serializeNode(node: any): string {
@@ -1282,7 +1393,7 @@
const alt = node.attrs.alt || '';
const size = node.attrs['data-size'] || node.attrs.size || 'full';
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
return `![${alt}${sizeSuffix}](${src})`;
return `![${alt}${sizeSuffix}](${src})\n`;
}
default:
return node.textContent || '';
@@ -1358,11 +1469,14 @@
const alt = child.attrs.alt || '';
const size = child.attrs['data-size'] || child.attrs.size || 'full';
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
if (parts.length > 0 && parts[parts.length - 1] !== '\n') {
parts.push('\n');
}
parts.push(`![${alt}${sizeSuffix}](${src})`);
} 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('');
@@ -1440,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) =>
@@ -1716,9 +1893,17 @@
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- <tiptask checked="false">$2</tiptask>');
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false">&nbsp;</tiptask>');
// Pre-process: preserve blank lines before image-only lines
// markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph.
// Insert a <div> marker that markdown-it passes through (html: true), then convert to <p></p>
src = src.replace(/\n\n(!\[[^\]]*\]\([^)]*\)\s*$)/gm, '\n\n<div data-img-gap></div>\n\n$1');
// Run markdown-it (single-pass parser — handles headings, bold, italic, strike, code, blockquote, lists, links, images, hr, tables, raw HTML)
let html = mdit.render(src);
// Post-process: convert image gap markers into empty paragraphs for ProseMirror
html = html.replace(/<div data-img-gap><\/div>\n?/g, '<p></p>\n');
// Post-process: strip trailing newlines inside code blocks (markdown-it adds them, TipTap shows them as blank lines)
html = html.replace(/<code([^>]*)>\n?/g, '<code$1>');
html = html.replace(/\n<\/code>/g, '</code>');
@@ -1835,7 +2020,7 @@
CustomTableCell,
CustomTableHeader,
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'editor-link' }, isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || !url.startsWith('javascript:') }),
CustomImage.configure({ HTMLAttributes: { class: 'editor-image' } }),
CustomImage.configure({ inline: true, HTMLAttributes: { class: 'editor-image' } }),
Highlight.configure({ multicolor: true }),
Typography,
Underline,
@@ -1853,6 +2038,7 @@
DetailsContent,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
SlashCommands,
MoveLineShortcuts,
NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
],
@@ -1872,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),
@@ -1896,6 +2090,7 @@
autoSave();
// Fix any blob: URLs from pasted web images
fixBlobImages();
if (showOutline) updateOutline();
},
});
editorReady = true;
@@ -1905,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;
@@ -1915,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 = '';
@@ -2625,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),
@@ -2801,6 +3003,16 @@
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<button
class="icon-btn"
class:active={showOutline}
onclick={() => { showOutline = !showOutline; if (showOutline) updateOutline(); }}
title="Outline"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/>
</svg>
</button>
<button
class="icon-btn"
class:active={showHistory}
@@ -2887,20 +3099,111 @@
<div class="editor-body-row">
<div class="editor-body">
{#if $sourceMode}
{#if $appConfig?.show_line_numbers}
<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
class="source-editor"
class:with-line-numbers={$appConfig?.show_line_numbers}
bind:this={sourceElement}
bind:value={sourceContent}
readonly={$readOnly}
onclick={() => { showOutline = false; }}
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;
const lines = val.split('\n');
// Find current line index
let pos = 0;
let curLine = 0;
for (let i = 0; i < lines.length; i++) {
if (pos + lines[i].length >= start) { curLine = i; break; }
pos += lines[i].length + 1;
}
if (e.key === 'ArrowUp' && curLine > 0) {
const tmp = lines[curLine];
lines[curLine] = lines[curLine - 1];
lines[curLine - 1] = tmp;
sourceContent = lines.join('\n');
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];
lines[curLine] = lines[curLine + 1];
lines[curLine + 1] = tmp;
sourceContent = lines.join('\n');
tick().then(() => {
const newPos = lines.slice(0, curLine + 1).join('\n').length + 1 + (start - pos);
ta.setSelectionRange(newPos, newPos);
pushSourceHistoryImmediate();
});
}
$editorDirty = true;
autoSave();
}
}}
onscroll={() => {
if ($appConfig?.show_line_numbers) {
const clip = sourceElement?.previousElementSibling as HTMLElement;
const gutter = clip?.firstElementChild as HTMLElement;
if (gutter) {
gutter.style.transform = `translateY(-${sourceElement.scrollTop}px)`;
}
}
}}
spellcheck="false"
></textarea>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div>
<div class="tiptap-wrapper" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}></div>
{/if}
</div>
@@ -2950,6 +3253,33 @@
{/if}
</div>
{/if}
{#if showOutline}
<div class="outline-panel">
<div class="outline-header">
<h3>Outline</h3>
<button class="outline-close" onclick={() => { showOutline = false; }}>
<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 outlineHeadings.length === 0}
<div class="outline-empty">No headings in this note.</div>
{:else}
<div class="outline-list">
{#each outlineHeadings as h}
<button
class="outline-item outline-level-{h.level}"
onclick={() => scrollToHeading(h.pos)}
>
{h.text}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
@@ -3778,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"
/>
@@ -4139,6 +4469,7 @@
overflow-y: auto;
padding: 8px 20px;
min-width: 0;
position: relative;
}
.editor-body:has(.source-editor) {
@@ -4269,6 +4600,90 @@
color: white;
}
.outline-panel {
width: 220px;
border-left: 1px solid var(--border-light);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.outline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border-light);
}
.outline-header h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
margin: 0;
}
.outline-close {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
}
.outline-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.outline-empty {
padding: 16px 14px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.outline-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.outline-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 4px 14px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.outline-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.outline-level-1 { padding-left: 14px; font-weight: 600; }
.outline-level-2 { padding-left: 26px; font-weight: 500; }
.outline-level-3 { padding-left: 38px; }
.outline-level-4 { padding-left: 50px; }
.outline-level-5 { padding-left: 62px; }
.outline-level-6 { padding-left: 74px; }
.source-editor {
width: 100%;
height: 100%;
@@ -4281,7 +4696,41 @@
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-clip {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 44px;
overflow: hidden;
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;
color: var(--text-secondary);
opacity: 0.5;
text-align: right;
user-select: none;
will-change: transform;
}
.line-numbers span {
display: block;
padding-right: 12px;
}
.tiptap-wrapper {
@@ -4392,6 +4841,70 @@
opacity: 0.7;
}
/* Syntax highlighting — light mode */
:global(.tiptap pre code .hljs-keyword),
:global(.tiptap pre code .hljs-selector-tag),
:global(.tiptap pre code .hljs-built_in) { color: #d73a49; }
:global(.tiptap pre code .hljs-string),
:global(.tiptap pre code .hljs-addition) { color: #032f62; }
:global(.tiptap pre code .hljs-number),
:global(.tiptap pre code .hljs-literal) { color: #005cc5; }
:global(.tiptap pre code .hljs-comment),
:global(.tiptap pre code .hljs-quote) { color: #6a737d; font-style: italic; }
:global(.tiptap pre code .hljs-function),
:global(.tiptap pre code .hljs-title) { color: #6f42c1; }
:global(.tiptap pre code .hljs-type),
:global(.tiptap pre code .hljs-title.class_) { color: #e36209; }
:global(.tiptap pre code .hljs-variable),
:global(.tiptap pre code .hljs-template-variable) { color: #e36209; }
:global(.tiptap pre code .hljs-attr),
:global(.tiptap pre code .hljs-attribute) { color: #005cc5; }
:global(.tiptap pre code .hljs-tag) { color: #22863a; }
:global(.tiptap pre code .hljs-name) { color: #22863a; }
:global(.tiptap pre code .hljs-meta) { color: #005cc5; }
:global(.tiptap pre code .hljs-deletion) { color: #b31d28; background: #ffeef0; }
:global(.tiptap pre code .hljs-symbol),
:global(.tiptap pre code .hljs-bullet) { color: #005cc5; }
:global(.tiptap pre code .hljs-regexp) { color: #032f62; }
:global(.tiptap pre code .hljs-params) { color: #24292e; }
:global(.tiptap pre code .hljs-punctuation) { color: #24292e; }
:global(.tiptap pre code .hljs-property) { color: #005cc5; }
:global(.tiptap pre code .hljs-selector-class) { color: #6f42c1; }
:global(.tiptap pre code .hljs-selector-id) { color: #005cc5; }
:global(.tiptap pre code .hljs-operator) { color: #d73a49; }
/* Syntax highlighting — dark mode */
:global(.dark .tiptap pre code .hljs-keyword),
:global(.dark .tiptap pre code .hljs-selector-tag),
:global(.dark .tiptap pre code .hljs-built_in) { color: #ff7b72; }
:global(.dark .tiptap pre code .hljs-string),
:global(.dark .tiptap pre code .hljs-addition) { color: #a5d6ff; }
:global(.dark .tiptap pre code .hljs-number),
:global(.dark .tiptap pre code .hljs-literal) { color: #79c0ff; }
:global(.dark .tiptap pre code .hljs-comment),
:global(.dark .tiptap pre code .hljs-quote) { color: #8b949e; font-style: italic; }
:global(.dark .tiptap pre code .hljs-function),
:global(.dark .tiptap pre code .hljs-title) { color: #d2a8ff; }
:global(.dark .tiptap pre code .hljs-type),
:global(.dark .tiptap pre code .hljs-title.class_) { color: #ffa657; }
:global(.dark .tiptap pre code .hljs-variable),
:global(.dark .tiptap pre code .hljs-template-variable) { color: #ffa657; }
:global(.dark .tiptap pre code .hljs-attr),
:global(.dark .tiptap pre code .hljs-attribute) { color: #79c0ff; }
:global(.dark .tiptap pre code .hljs-tag) { color: #7ee787; }
:global(.dark .tiptap pre code .hljs-name) { color: #7ee787; }
:global(.dark .tiptap pre code .hljs-meta) { color: #79c0ff; }
:global(.dark .tiptap pre code .hljs-deletion) { color: #ffdcd7; background: #67060c; }
:global(.dark .tiptap pre code .hljs-symbol),
:global(.dark .tiptap pre code .hljs-bullet) { color: #79c0ff; }
:global(.dark .tiptap pre code .hljs-regexp) { color: #a5d6ff; }
:global(.dark .tiptap pre code .hljs-params) { color: #c9d1d9; }
:global(.dark .tiptap pre code .hljs-punctuation) { color: #c9d1d9; }
:global(.dark .tiptap pre code .hljs-property) { color: #79c0ff; }
:global(.dark .tiptap pre code .hljs-selector-class) { color: #d2a8ff; }
:global(.dark .tiptap pre code .hljs-selector-id) { color: #79c0ff; }
:global(.dark .tiptap pre code .hljs-operator) { color: #ff7b72; }
.code-lang-overlay {
position: fixed;
inset: 0;
+12 -1
View File
@@ -308,6 +308,7 @@
let pdfHeight = $state($appConfig?.pdf_height ?? 600);
let titleMode = $state($appConfig?.title_mode ?? 'input');
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false);
@@ -360,12 +361,13 @@
$appConfig.pdf_height = pdfHeight;
$appConfig.title_mode = titleMode;
$appConfig.hide_title_in_body = hideTitleInBody;
$appConfig.show_line_numbers = showLineNumbers;
$appConfig.default_view_mode = defaultViewMode;
$appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks;
}
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -625,6 +627,15 @@
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Show line numbers</span>
<span class="setting-desc">Display line numbers in the markdown source editor</span>
</span>
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Open notes in View Mode</span>