From 0d11fd01dd1782e5c8c337e140949e83980d5535 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Fri, 4 Sep 2026 14:46:30 +0200 Subject: [PATCH] fix: preserve hidden note title across editor modes --- src/lib/components/Editor.svelte | 56 ++++------------ src/lib/editor/titleVisibility.ts | 46 +++++++++++++ tests/title-visibility.test.mjs | 104 ++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 44 deletions(-) create mode 100644 src/lib/editor/titleVisibility.ts create mode 100644 tests/title-visibility.test.mjs diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index c38383c..33fd809 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -54,6 +54,7 @@ import { convertListNode, type MixedListName } from '$lib/editor/mixedLists'; import { clearFormatting } from '$lib/editor/clearFormatting'; import { serializeInlineMarkdown } from '$lib/editor/markdown'; + import { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility'; import { replaceWithWikiLink } from '$lib/editor/wikiLinks'; import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths'; import GraphView from './GraphView.svelte'; @@ -117,9 +118,7 @@ let hasPendingBlobs = false; let lastSourceMode = $sourceMode; let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null); - let titleWasStripped = false; - let strippedTitle = ''; - let strippedHeadingPrefix = ''; + let hiddenTitleHeading: HiddenTitleHeading | null = null; let taskRevealTimer: ReturnType | null = null; let taskRevealElement: HTMLElement | null = null; let taskRevealRequest = 0; @@ -3403,44 +3402,13 @@ } function stripTitleH1(md: string): string { - const title = $activeNote?.meta.title; - if (!$appConfig?.hide_title_in_body || !title) { - titleWasStripped = false; - strippedTitle = ''; - strippedHeadingPrefix = ''; - return md; - } - // Find the first non-empty line - const lines = md.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === '') continue; - // Check if it's a heading (any level) matching the note title - // Normalize: lowercase, collapse whitespace, strip common separators (- - _) - const normalize = (s: string) => s.trim().toLowerCase().replace(/[\s\-—_]+/g, ' '); - const match = line.match(/^(#{1,6})\s+(.+)$/); - if (match && normalize(match[2]) === normalize(title)) { - titleWasStripped = true; - strippedTitle = title.trim(); - strippedHeadingPrefix = match[1]; // preserve original heading level (e.g. "##") - lines.splice(i, 1); - // Also remove a trailing blank line after the heading if present - if (i < lines.length && lines[i].trim() === '') { - lines.splice(i, 1); - } - return lines.join('\n'); - } - break; // First non-empty line isn't a matching heading, stop - } - titleWasStripped = false; - strippedTitle = ''; - strippedHeadingPrefix = ''; - return md; + const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false); + hiddenTitleHeading = result.hiddenTitle; + return result.markdown; } function restoreTitleH1(md: string): string { - if (!titleWasStripped || !strippedTitle) return md; - return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`; + return restoreTitleHeading(md, hiddenTitleHeading); } function editorToMarkdown(): string { @@ -5794,7 +5762,7 @@ ? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length : 0; const docNonWs = docNonWhitespace(); - sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? ''); + sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? '')); resetSourceHistory(sourceContent); lastSourceMode = true; const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0; @@ -5821,20 +5789,20 @@ }; if (isMobile) { // Mobile: editor stays in DOM, just update its content - const content = srcText || ($activeNote?.content ?? ''); + const content = srcText; if (editor) { ignoreNextUpdate = true; - editor.commands.setContent(markdownToHtml(content)); + editor.commands.setContent(markdownToHtml(restoreTitleH1(content))); tick().then(restoreRichCaret); } } else { // Desktop: destroy old editor (its DOM element is gone), // wait for DOM to swap textarea→div, then create editor on new element. destroyEditor(); - const content = srcText || ($activeNote?.content ?? ''); + const content = srcText; tick().then(() => { if (editorElement && !editor) { - createEditor(content); + createEditor(restoreTitleH1(content)); restoreRichCaret(); } }); @@ -5967,7 +5935,7 @@ const oldPath = $activeNotePath; $activeNote.meta.title = newTitle; // Update stripped title so restoreTitleH1 uses the new title - if (titleWasStripped) strippedTitle = newTitle; + if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle }; $editorDirty = true; // Force save current editor content before renaming so disk is up-to-date await forceSave(); diff --git a/src/lib/editor/titleVisibility.ts b/src/lib/editor/titleVisibility.ts new file mode 100644 index 0000000..7a8c2c0 --- /dev/null +++ b/src/lib/editor/titleVisibility.ts @@ -0,0 +1,46 @@ +export type HiddenTitleHeading = { + headingPrefix: string; + title: string; +}; + +type TitleHeadingResult = { + markdown: string; + hiddenTitle: HiddenTitleHeading | null; +}; + +function normalizeTitle(value: string): string { + return value.trim().toLowerCase().replace(/[\s\-_\u2014]+/g, ' '); +} + +export function stripTitleHeading( + markdown: string, + title: string | undefined, + hideTitle: boolean, +): TitleHeadingResult { + if (!hideTitle || !title) return { markdown, hiddenTitle: null }; + + const lines = markdown.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === '') continue; + + const match = line.match(/^(#{1,6})\s+(.+)$/); + if (match && normalizeTitle(match[2]) === normalizeTitle(title)) { + const hiddenTitle = { headingPrefix: match[1], title: title.trim() }; + lines.splice(i, 1); + if (i < lines.length && lines[i].trim() === '') lines.splice(i, 1); + return { markdown: lines.join('\n'), hiddenTitle }; + } + break; + } + + return { markdown, hiddenTitle: null }; +} + +export function restoreTitleHeading( + markdown: string, + hiddenTitle: HiddenTitleHeading | null, +): string { + if (!hiddenTitle) return markdown; + return `${hiddenTitle.headingPrefix} ${hiddenTitle.title}\n\n${markdown}`; +} diff --git a/tests/title-visibility.test.mjs b/tests/title-visibility.test.mjs new file mode 100644 index 0000000..71a497d --- /dev/null +++ b/tests/title-visibility.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const { restoreTitleHeading, stripTitleHeading } = await import( + new URL('../src/lib/editor/titleVisibility.ts', import.meta.url) +); +const editorSource = await readFile( + new URL('../src/lib/components/Editor.svelte', import.meta.url), + 'utf8' +); + +test('rich to source transition strips the restored title before display', () => { + const start = editorSource.indexOf('if (isSource && !lastSourceMode) {'); + const end = editorSource.indexOf('} else if (!isSource && lastSourceMode)', start); + + assert.notEqual(start, -1, 'rich to source transition was not found'); + assert.notEqual(end, -1, 'rich to source transition boundary was not found'); + assert.match( + editorSource.slice(start, end), + /sourceContent\s*=\s*stripTitleH1\(\s*editor\s*\?\s*editorToMarkdown\(\)\s*:\s*\(\$activeNote\?\.content\s*\?\?\s*''\)\s*\)\s*;/ + ); +}); + +test('source to rich transition preserves the hidden title on desktop and mobile', () => { + const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {'); + const end = editorSource.indexOf('// Tauri drag-drop listener', start); + const transition = editorSource.slice(start, end); + + assert.notEqual(start, -1, 'source to rich transition was not found'); + assert.notEqual(end, -1, 'source to rich transition boundary was not found'); + assert.match( + transition, + /editor\.commands\.setContent\(\s*markdownToHtml\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*\)\s*;/ + ); + assert.match(transition, /createEditor\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*;/); +}); + +test('source to rich transition preserves an empty source body on desktop and mobile', () => { + const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {'); + const end = editorSource.indexOf('// Tauri drag-drop listener', start); + const transition = editorSource.slice(start, end); + const contentAssignments = [...transition.matchAll(/const content = ([^;]+);/g)] + .map((match) => match[1].trim()); + + assert.deepEqual(contentAssignments, ['srcText', 'srcText']); + assert.doesNotMatch(transition, /srcText\s*\|\|/); +}); + +test('keeps a hidden title through source to rich to source and save', () => { + const persisted = '# Note title\n\n## Something else\n\nBody\n'; + const body = '## Something else\n\nBody\n'; + + const initialSource = stripTitleHeading(persisted, 'Note title', true); + const rich = stripTitleHeading( + restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle), + 'Note title', + true + ); + const toggledSource = stripTitleHeading( + restoreTitleHeading(rich.markdown, rich.hiddenTitle), + 'Note title', + true + ); + + assert.equal(rich.markdown, body); + assert.equal(toggledSource.markdown, body); + assert.equal(restoreTitleHeading(toggledSource.markdown, toggledSource.hiddenTitle), persisted); +}); + +test('keeps a title-only note visually empty and saves one title heading', () => { + const initialSource = stripTitleHeading('# Note title\n', 'Note title', true); + const rich = stripTitleHeading( + restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle), + 'Note title', + true + ); + const saved = restoreTitleHeading(rich.markdown, rich.hiddenTitle); + + assert.equal(initialSource.markdown, ''); + assert.equal(rich.markdown, ''); + assert.equal((saved.match(/^# Note title$/gm) ?? []).length, 1); +}); + +test('replaces hidden title state when an unrelated note loads', () => { + const markdown = '## Something else\n\nOther body\n'; + const start = editorSource.indexOf('function stripTitleH1(md: string): string {'); + const end = editorSource.indexOf('function restoreTitleH1(md: string): string {', start); + + assert.deepEqual(stripTitleHeading(markdown, 'Another note', true), { + markdown, + hiddenTitle: null + }); + assert.match(editorSource.slice(start, end), /hiddenTitleHeading\s*=\s*result\.hiddenTitle\s*;/); +}); + +test('leaves the title visible when title hiding is disabled', () => { + const persisted = '# Note title\n\n## Something else\n'; + + assert.deepEqual(stripTitleHeading(persisted, 'Note title', false), { + markdown: persisted, + hiddenTitle: null + }); +});