Add Markdown source highlighting (#173)

This commit is contained in:
Yuri Karamian
2026-08-03 09:00:43 +02:00
parent 4a1045b41e
commit f4e05ed9c4
6 changed files with 139 additions and 26 deletions
+2
View File
@@ -1398,6 +1398,7 @@ pub fn set_general_settings(
show_line_numbers: bool, show_line_numbers: bool,
show_link_arrows: bool, show_link_arrows: bool,
default_view_mode: bool, default_view_mode: bool,
new_notes_in_source_mode: bool,
show_tray_icon: bool, show_tray_icon: bool,
close_to_tray: bool, close_to_tray: bool,
enable_wiki_links: bool, enable_wiki_links: bool,
@@ -1432,6 +1433,7 @@ pub fn set_general_settings(
config.show_line_numbers = show_line_numbers; config.show_line_numbers = show_line_numbers;
config.show_link_arrows = show_link_arrows; config.show_link_arrows = show_link_arrows;
config.default_view_mode = default_view_mode; 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.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray; config.close_to_tray = close_to_tray;
config.enable_wiki_links = enable_wiki_links; config.enable_wiki_links = enable_wiki_links;
+3
View File
@@ -197,6 +197,8 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub default_view_mode: bool, pub default_view_mode: bool,
#[serde(default)] #[serde(default)]
pub new_notes_in_source_mode: bool,
#[serde(default)]
pub show_tray_icon: bool, pub show_tray_icon: bool,
#[serde(default)] #[serde(default)]
pub close_to_tray: bool, pub close_to_tray: bool,
@@ -315,6 +317,7 @@ impl Default for AppConfig {
ai_model: "claude-sonnet-4-6".to_string(), ai_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None, ai_writing_style: None,
default_view_mode: false, default_view_mode: false,
new_notes_in_source_mode: false,
show_tray_icon: false, show_tray_icon: false,
close_to_tray: false, close_to_tray: false,
enable_wiki_links: true, enable_wiki_links: true,
+2
View File
@@ -265,6 +265,7 @@ export async function setGeneralSettings(
showLineNumbers: boolean, showLineNumbers: boolean,
showLinkArrows: boolean, showLinkArrows: boolean,
defaultViewMode: boolean, defaultViewMode: boolean,
newNotesInSourceMode: boolean,
showTrayIcon: boolean, showTrayIcon: boolean,
closeToTray: boolean, closeToTray: boolean,
enableWikiLinks: boolean, enableWikiLinks: boolean,
@@ -291,6 +292,7 @@ export async function setGeneralSettings(
showLineNumbers, showLineNumbers,
showLinkArrows, showLinkArrows,
defaultViewMode, defaultViewMode,
newNotesInSourceMode,
showTrayIcon, showTrayIcon,
closeToTray, closeToTray,
enableWikiLinks, enableWikiLinks,
+117 -25
View File
@@ -24,6 +24,8 @@
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import { common, createLowlight } from 'lowlight'; import { common, createLowlight } from 'lowlight';
import powershell from 'highlight.js/lib/languages/powershell'; 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 MarkdownIt from 'markdown-it';
import markdownItMark from 'markdown-it-mark'; import markdownItMark from 'markdown-it-mark';
import markdownItSup from 'markdown-it-sup'; import markdownItSup from 'markdown-it-sup';
@@ -56,6 +58,9 @@
import ResizeHandle from './ResizeHandle.svelte'; import ResizeHandle from './ResizeHandle.svelte';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; 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 // Track virtual keyboard height on mobile via visualViewport
let keyboardHeight = $state(0); let keyboardHeight = $state(0);
@@ -68,6 +73,7 @@
let editorElement = $state<HTMLDivElement>(null!); let editorElement = $state<HTMLDivElement>(null!);
let sourceElement = $state<HTMLTextAreaElement>(null!); let sourceElement = $state<HTMLTextAreaElement>(null!);
let sourceHighlightElement = $state<HTMLPreElement>(null!);
const LARGE_DOC_CHARS = 100_000; const LARGE_DOC_CHARS = 100_000;
let isLargeDoc = $state(false); let isLargeDoc = $state(false);
let editor: Editor | null = null; let editor: Editor | null = null;
@@ -83,6 +89,10 @@
}); });
let editorReady = $state(false); let editorReady = $state(false);
let sourceContent = $state(''); 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 sourceHistory: Array<{ content: string; cursor: number }> = [];
let sourceHistoryIndex = -1; let sourceHistoryIndex = -1;
let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null; let sourceHistoryTimer: ReturnType<typeof setTimeout> | 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() { function clearTaskReveal() {
if (taskRevealTimer) clearTimeout(taskRevealTimer); if (taskRevealTimer) clearTimeout(taskRevealTimer);
taskRevealTimer = null; taskRevealTimer = null;
@@ -3271,13 +3293,16 @@
const revealRequest = ++taskRevealRequest; const revealRequest = ++taskRevealRequest;
const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null; const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null;
loadedPath = path; loadedPath = path;
lastSourceMode = $sourceMode;
isLoadingNote = true; isLoadingNote = true;
isLargeDoc = content.length > LARGE_DOC_CHARS; 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. New notes stay editable and
// Viewer mode (external file) always forces read-only. // can opt into source mode without changing how existing notes choose their mode.
const isViewer = !!get(viewerNote); const isViewer = !!get(viewerNote);
const isNewNote = $activeNote?.meta.title === 'Untitled' && !content.replace(/^---[\s\S]*?---\s*/, '').trim(); 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)); const shouldBeReadOnly = isViewer ? true : (isNewNote ? false : ($appConfig?.default_view_mode ?? false));
$readOnly = shouldBeReadOnly; $readOnly = shouldBeReadOnly;
if (editor) editor.setEditable(!shouldBeReadOnly); if (editor) editor.setEditable(!shouldBeReadOnly);
@@ -6240,9 +6265,10 @@
<div class="editor-body"> <div class="editor-body">
{#if isMobile} {#if isMobile}
<!-- Mobile: both views always in DOM, toggled via display to avoid slow editor re-creation --> <!-- Mobile: both views always in DOM, toggled via display to avoid slow editor re-creation -->
<div class="source-editor-layer" style={$sourceMode ? '' : 'display:none'}>
<pre class="source-highlight" aria-hidden="true" bind:this={sourceHighlightElement}><code>{@html sourceHighlightHtml}</code></pre>
<textarea <textarea
class="source-editor" class="source-editor"
style={$sourceMode ? '' : 'display:none'}
bind:this={sourceElement} bind:this={sourceElement}
bind:value={sourceContent} bind:value={sourceContent}
readonly={$readOnly} readonly={$readOnly}
@@ -6282,8 +6308,10 @@
return; return;
} }
}} }}
onscroll={syncSourceEditorScroll}
spellcheck="false" spellcheck="false"
></textarea> ></textarea>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }}></div> <div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }}></div>
{:else} {:else}
@@ -6298,6 +6326,8 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="source-editor-layer" class:with-line-numbers={$appConfig?.show_line_numbers}>
<pre class="source-highlight" aria-hidden="true" bind:this={sourceHighlightElement}><code>{@html sourceHighlightHtml}</code></pre>
<textarea <textarea
class="source-editor" class="source-editor"
class:with-line-numbers={$appConfig?.show_line_numbers} class:with-line-numbers={$appConfig?.show_line_numbers}
@@ -6381,17 +6411,10 @@
autoSave(); autoSave();
} }
}} }}
onscroll={() => { onscroll={syncSourceEditorScroll}
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" spellcheck="false"
></textarea> ></textarea>
</div>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div> <div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div>
@@ -8587,36 +8610,74 @@
.outline-level-5 { padding-left: 62px; } .outline-level-5 { padding-left: 62px; }
.outline-level-6 { padding-left: 74px; } .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 { .source-editor {
box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: none; border: none;
background: none;
color: var(--text-primary);
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.3; line-height: 1.3;
resize: none;
outline: none;
padding: 0 0 var(--editor-scroll-past-end, 65vh); 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; white-space: pre-wrap;
word-break: break-word; 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; 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; padding-left: 48px;
/* The line-number gutter has one fixed row per line, so wrapping would desync it. /* 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) */ Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */
white-space: pre; white-space: pre;
word-break: normal;
}
.source-editor.with-line-numbers {
overflow-x: auto; 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 { .line-numbers-clip {
@@ -8646,6 +8707,36 @@
padding-right: 12px; 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 { .tiptap-wrapper {
height: 100%; height: 100%;
user-select: text; user-select: text;
@@ -11326,6 +11417,7 @@
font-size: var(--editor-font-size, 16px) !important; font-size: var(--editor-font-size, 16px) !important;
} }
.editor-container.mobile .source-highlight,
.editor-container.mobile .source-editor { .editor-container.mobile .source-editor {
padding: 8px 16px 220px; padding: 8px 16px 220px;
font-size: var(--editor-font-size, 15px); font-size: var(--editor-font-size, 15px);
+14 -1
View File
@@ -717,6 +717,7 @@
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false); let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true); let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false); 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 showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false); let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true); let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
@@ -789,6 +790,7 @@
$appConfig.show_line_numbers = showLineNumbers; $appConfig.show_line_numbers = showLineNumbers;
$appConfig.show_link_arrows = showLinkArrows; $appConfig.show_link_arrows = showLinkArrows;
$appConfig.default_view_mode = defaultViewMode; $appConfig.default_view_mode = defaultViewMode;
$appConfig.new_notes_in_source_mode = newNotesInSourceMode;
$appConfig.show_tray_icon = showTrayIcon; $appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray; $appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks; $appConfig.enable_wiki_links = enableWikiLinks;
@@ -798,7 +800,7 @@
$appConfig.show_daily_notes = showDailyNotes; $appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash; $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)); .catch((e) => console.error('Failed to save general settings:', e));
} }
@@ -988,6 +990,7 @@
pdfPreview = $appConfig.pdf_preview ?? false; pdfPreview = $appConfig.pdf_preview ?? false;
pdfHeight = $appConfig.pdf_height ?? 600; pdfHeight = $appConfig.pdf_height ?? 600;
titleMode = $appConfig.title_mode ?? 'input'; titleMode = $appConfig.title_mode ?? 'input';
newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false;
showAllNotes = $appConfig.show_all_notes ?? true; showAllNotes = $appConfig.show_all_notes ?? true;
showQuickAccess = $appConfig.show_quick_access ?? true; showQuickAccess = $appConfig.show_quick_access ?? true;
showTasks = $appConfig.show_tasks ?? true; showTasks = $appConfig.show_tasks ?? true;
@@ -1309,8 +1312,18 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Open new notes in source mode</span>
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
</span>
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Wiki Links & Graph</h3> <h3>Wiki Links & Graph</h3>
<label class="setting-toggle"> <label class="setting-toggle">
+1
View File
@@ -129,6 +129,7 @@ export interface AppConfig {
ai_model: string; ai_model: string;
ai_writing_style: string | null; ai_writing_style: string | null;
default_view_mode: boolean; default_view_mode: boolean;
new_notes_in_source_mode: boolean;
show_tray_icon: boolean; show_tray_icon: boolean;
close_to_tray: boolean; close_to_tray: boolean;
enable_wiki_links: boolean; enable_wiki_links: boolean;