From 06d53cea66b7722ebb46c69092f2e5b78700c6ae Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 16 Feb 2026 23:36:02 +0100 Subject: [PATCH] v1.1.2 - Alt+Arrow move lines, Ctrl+Shift+M source toggle, code syntax highlighting, outline panel, image spacing fix, daily notes --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 6 +- src-tauri/src/types.rs | 3 + src-tauri/tauri.conf.json | 2 +- src/lib/api.ts | 2 + src/lib/components/AppLayout.svelte | 7 +- src/lib/components/Editor.svelte | 398 +++++++++++++++++++++++- src/lib/components/SettingsPanel.svelte | 13 +- 10 files changed, 422 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index a30b4a0..df2da33 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.1.1", + "version": "1.1.2", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9eb4e9f..1141de5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.1.1" +version = "1.1.2" dependencies = [ "chrono", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6b10962..add1919 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.1.1" +version = "1.1.2" description = "Local markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9cc5bfd..ae42836 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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() } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 85df552..8c53cef 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fe8ba4c..61a7ac4 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.1", + "version": "1.1.2", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/api.ts b/src/lib/api.ts index d28fc16..b64ae96 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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, diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index fc4989f..d0cc2ad 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -25,7 +25,8 @@ activeNotePath, editorDirty, showInfo, - showSettings + showSettings, + sourceMode } from '$lib/stores/app'; const appWindow = getCurrentWindow(); @@ -169,6 +170,10 @@ e.preventDefault(); editor?.forceSave(); } + if (mod && e.shiftKey && e.key === 'M') { + e.preventDefault(); + $sourceMode = !$sourceMode; + } if (e.key === 'Escape') { if ($showSettings) $showSettings = false; else if ($showInfo) $showInfo = false; diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bffa5bf..d9f8f43 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -99,6 +99,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([]); + + 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); @@ -377,6 +400,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() { @@ -1177,9 +1260,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 +1285,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 +1387,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,6 +1463,9 @@ 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 || ''}$`); @@ -1716,9 +1824,17 @@ src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- $2'); src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1-  '); + // Pre-process: preserve blank lines before image-only lines + // markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph. + // Insert a
marker that markdown-it passes through (html: true), then convert to

+ src = src.replace(/\n\n(!\[[^\]]*\]\([^)]*\)\s*$)/gm, '\n\n
\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>\n?/g, '

\n'); + // Post-process: strip trailing newlines inside code blocks (markdown-it adds them, TipTap shows them as blank lines) html = html.replace(/]*)>\n?/g, ''); html = html.replace(/\n<\/code>/g, ''); @@ -1835,7 +1951,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 +1969,7 @@ DetailsContent, TextAlign.configure({ types: ['heading', 'paragraph'] }), SlashCommands, + MoveLineShortcuts, NoteSearchExtension, ...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []), ], @@ -1896,6 +2013,7 @@ autoSave(); // Fix any blob: URLs from pasted web images fixBlobImages(); + if (showOutline) updateOutline(); }, }); editorReady = true; @@ -2801,6 +2919,16 @@ +
{/if} + {#if showOutline} +
+
+

Outline

+ +
+ {#if outlineHeadings.length === 0} +
No headings in this note.
+ {:else} +
+ {#each outlineHeadings as h} + + {/each} +
+ {/if} +
+ {/if}
@@ -4139,6 +4346,7 @@ overflow-y: auto; padding: 8px 20px; min-width: 0; + position: relative; } .editor-body:has(.source-editor) { @@ -4269,6 +4477,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%; @@ -4284,6 +4576,34 @@ user-select: text; } + .source-editor.with-line-numbers { + padding-left: 48px; + } + + .line-numbers { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 44px; + overflow: hidden; + display: flex; + flex-direction: column; + padding-top: 0; + 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; + pointer-events: none; + } + + .line-numbers span { + padding-right: 12px; + } + .tiptap-wrapper { height: 100%; user-select: text; @@ -4392,6 +4712,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; diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index 8442550..d534a47 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -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 @@ +