From f4e05ed9c4f2723041a10adc3e1d13f7547ced1a Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 3 Aug 2026 09:00:43 +0200 Subject: [PATCH] Add Markdown source highlighting (#173) --- src-tauri/src/commands.rs | 2 + src-tauri/src/types.rs | 3 + src/lib/api.ts | 2 + src/lib/components/Editor.svelte | 142 +++++++++++++++++++----- src/lib/components/SettingsPanel.svelte | 15 ++- src/lib/types.ts | 1 + 6 files changed, 139 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 84bd924..68a2ec4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1398,6 +1398,7 @@ pub fn set_general_settings( show_line_numbers: bool, show_link_arrows: bool, default_view_mode: bool, + new_notes_in_source_mode: bool, show_tray_icon: bool, close_to_tray: bool, enable_wiki_links: bool, @@ -1432,6 +1433,7 @@ pub fn set_general_settings( config.show_line_numbers = show_line_numbers; config.show_link_arrows = show_link_arrows; config.default_view_mode = default_view_mode; + config.new_notes_in_source_mode = new_notes_in_source_mode; config.show_tray_icon = show_tray_icon; config.close_to_tray = close_to_tray; config.enable_wiki_links = enable_wiki_links; diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index db4cebe..1dc1691 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -197,6 +197,8 @@ pub struct AppConfig { #[serde(default)] pub default_view_mode: bool, #[serde(default)] + pub new_notes_in_source_mode: bool, + #[serde(default)] pub show_tray_icon: bool, #[serde(default)] pub close_to_tray: bool, @@ -315,6 +317,7 @@ impl Default for AppConfig { ai_model: "claude-sonnet-4-6".to_string(), ai_writing_style: None, default_view_mode: false, + new_notes_in_source_mode: false, show_tray_icon: false, close_to_tray: false, enable_wiki_links: true, diff --git a/src/lib/api.ts b/src/lib/api.ts index 8cf0ba3..8282556 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -265,6 +265,7 @@ export async function setGeneralSettings( showLineNumbers: boolean, showLinkArrows: boolean, defaultViewMode: boolean, + newNotesInSourceMode: boolean, showTrayIcon: boolean, closeToTray: boolean, enableWikiLinks: boolean, @@ -291,6 +292,7 @@ export async function setGeneralSettings( showLineNumbers, showLinkArrows, defaultViewMode, + newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bd9cdbf..1db483a 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -24,6 +24,8 @@ import TextAlign from '@tiptap/extension-text-align'; import { common, createLowlight } from 'lowlight'; import powershell from 'highlight.js/lib/languages/powershell'; + import hljs from 'highlight.js/lib/core'; + import markdownLanguage from 'highlight.js/lib/languages/markdown'; import MarkdownIt from 'markdown-it'; import markdownItMark from 'markdown-it-mark'; import markdownItSup from 'markdown-it-sup'; @@ -56,6 +58,9 @@ import ResizeHandle from './ResizeHandle.svelte'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; + const sourceHighlighter = hljs.newInstance(); + sourceHighlighter.registerLanguage('markdown', markdownLanguage); + const SOURCE_HIGHLIGHT_MAX_CHARS = 32_000; // Track virtual keyboard height on mobile via visualViewport let keyboardHeight = $state(0); @@ -68,6 +73,7 @@ let editorElement = $state(null!); let sourceElement = $state(null!); + let sourceHighlightElement = $state(null!); const LARGE_DOC_CHARS = 100_000; let isLargeDoc = $state(false); let editor: Editor | null = null; @@ -83,6 +89,10 @@ }); let editorReady = $state(false); let sourceContent = $state(''); + let sourceHighlightHtml = $derived.by(() => { + if (sourceContent.length > SOURCE_HIGHLIGHT_MAX_CHARS) return escapeHtml(sourceContent); + return sourceHighlighter.highlight(sourceContent, { language: 'markdown', ignoreIllegals: true }).value; + }); let sourceHistory: Array<{ content: string; cursor: number }> = []; let sourceHistoryIndex = -1; let sourceHistoryTimer: ReturnType | null = null; @@ -123,6 +133,18 @@ }); } + function syncSourceEditorScroll() { + if (sourceHighlightElement && sourceElement) { + sourceHighlightElement.scrollTop = sourceElement.scrollTop; + sourceHighlightElement.scrollLeft = sourceElement.scrollLeft; + } + if ($appConfig?.show_line_numbers && sourceElement) { + const clip = sourceElement.closest('.editor-body')?.querySelector('.line-numbers-clip'); + const gutter = clip?.firstElementChild as HTMLElement | null; + if (gutter) gutter.style.transform = `translateY(-${sourceElement.scrollTop}px)`; + } + } + function clearTaskReveal() { if (taskRevealTimer) clearTimeout(taskRevealTimer); taskRevealTimer = null; @@ -3271,13 +3293,16 @@ const revealRequest = ++taskRevealRequest; const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null; loadedPath = path; - lastSourceMode = $sourceMode; isLoadingNote = true; isLargeDoc = content.length > LARGE_DOC_CHARS; - // Apply default view mode when switching notes - but new notes always open in edit mode. - // Viewer mode (external file) always forces read-only. + // Viewer mode (external file) always forces read-only. New notes stay editable and + // can opt into source mode without changing how existing notes choose their mode. const isViewer = !!get(viewerNote); const isNewNote = $activeNote?.meta.title === 'Untitled' && !content.replace(/^---[\s\S]*?---\s*/, '').trim(); + if (isNewNote && ($appConfig?.new_notes_in_source_mode ?? false)) { + $sourceMode = true; + } + lastSourceMode = $sourceMode; const shouldBeReadOnly = isViewer ? true : (isNewNote ? false : ($appConfig?.default_view_mode ?? false)); $readOnly = shouldBeReadOnly; if (editor) editor.setEditable(!shouldBeReadOnly); @@ -6240,9 +6265,10 @@
{#if isMobile} +
+ +
{ closeLinkContextMenu(); handleEditorClick(e); }}>
{:else} @@ -6298,6 +6326,8 @@
{/if} +
+ +
{:else}
{ closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}>
@@ -8587,36 +8610,74 @@ .outline-level-5 { padding-left: 62px; } .outline-level-6 { padding-left: 74px; } + .source-editor-layer { + position: relative; + width: 100%; + height: 100%; + max-width: var(--editor-content-width, none); + margin: 0 auto; + } + + .source-highlight, .source-editor { + box-sizing: border-box; width: 100%; height: 100%; border: none; - background: none; - color: var(--text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: var(--editor-font-size, 14px); line-height: 1.3; - resize: none; - outline: none; padding: 0 0 var(--editor-scroll-past-end, 65vh); - margin: 0 auto; - max-width: var(--editor-content-width, none); - user-select: text; - /* Wrap long lines instead of horizontal-scrolling (matches mobile). (issue #100) */ white-space: pre-wrap; word-break: break-word; + } + + .source-highlight { + position: absolute; + inset: 0; + z-index: 0; + margin: 0; + overflow: hidden; + pointer-events: none; + user-select: none; + color: var(--text-primary); + } + + .source-highlight code { + font: inherit; + white-space: inherit; + color: inherit; + } + + .source-editor { + position: absolute; + inset: 0; + z-index: 1; + background: transparent; + color: transparent; + caret-color: var(--text-primary); + resize: none; + outline: none; + user-select: text; overflow-x: hidden; } - .source-editor.with-line-numbers { + .source-editor-layer.with-line-numbers { + max-width: none; + margin: 0; + } + + .source-editor.with-line-numbers, + .source-editor-layer.with-line-numbers .source-highlight { padding-left: 48px; /* The line-number gutter has one fixed row per line, so wrapping would desync it. Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */ white-space: pre; + word-break: normal; + } + + .source-editor.with-line-numbers { overflow-x: auto; - /* The line-number gutter is pinned to the left edge, so don't center/cap here. (#137) */ - max-width: none; - margin: 0; } .line-numbers-clip { @@ -8646,6 +8707,36 @@ padding-right: 12px; } + /* Markdown source highlighting stays color-only so glyph metrics match the textarea caret. */ + :global(.source-highlight .hljs-section), + :global(.source-highlight .hljs-strong) { + color: var(--accent); + font-weight: inherit; + } + + :global(.source-highlight .hljs-string), + :global(.source-highlight .hljs-link) { + color: color-mix(in srgb, var(--accent) 78%, var(--text-primary)); + } + + :global(.source-highlight .hljs-bullet), + :global(.source-highlight .hljs-symbol), + :global(.source-highlight .hljs-meta), + :global(.source-highlight .hljs-attr) { + color: var(--success); + } + + :global(.source-highlight .hljs-quote), + :global(.source-highlight .hljs-code) { + color: var(--text-secondary); + font-style: inherit; + } + + :global(.source-highlight .hljs-emphasis) { + color: color-mix(in srgb, var(--accent) 60%, var(--text-primary)); + font-style: inherit; + } + .tiptap-wrapper { height: 100%; user-select: text; @@ -11326,6 +11417,7 @@ font-size: var(--editor-font-size, 16px) !important; } + .editor-container.mobile .source-highlight, .editor-container.mobile .source-editor { padding: 8px 16px 220px; font-size: var(--editor-font-size, 15px); diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index b15d053..63678fd 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -717,6 +717,7 @@ let showLineNumbers = $state($appConfig?.show_line_numbers ?? false); let showLinkArrows = $state($appConfig?.show_link_arrows ?? true); let defaultViewMode = $state($appConfig?.default_view_mode ?? false); + let newNotesInSourceMode = $state($appConfig?.new_notes_in_source_mode ?? false); let showTrayIcon = $state($appConfig?.show_tray_icon ?? false); let closeToTray = $state($appConfig?.close_to_tray ?? false); let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true); @@ -789,6 +790,7 @@ $appConfig.show_line_numbers = showLineNumbers; $appConfig.show_link_arrows = showLinkArrows; $appConfig.default_view_mode = defaultViewMode; + $appConfig.new_notes_in_source_mode = newNotesInSourceMode; $appConfig.show_tray_icon = showTrayIcon; $appConfig.close_to_tray = closeToTray; $appConfig.enable_wiki_links = enableWikiLinks; @@ -798,7 +800,7 @@ $appConfig.show_daily_notes = showDailyNotes; $appConfig.show_trash = showTrash; } - setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash) + setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash) .catch((e) => console.error('Failed to save general settings:', e)); } @@ -988,6 +990,7 @@ pdfPreview = $appConfig.pdf_preview ?? false; pdfHeight = $appConfig.pdf_height ?? 600; titleMode = $appConfig.title_mode ?? 'input'; + newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false; showAllNotes = $appConfig.show_all_notes ?? true; showQuickAccess = $appConfig.show_quick_access ?? true; showTasks = $appConfig.show_tasks ?? true; @@ -1309,8 +1312,18 @@ + +

Wiki Links & Graph