fix: preserve hidden note title across editor modes

This commit is contained in:
Yuri Karamian
2026-09-04 14:46:30 +02:00
parent 0aaa05ce5c
commit 0d11fd01dd
3 changed files with 162 additions and 44 deletions
+12 -44
View File
@@ -54,6 +54,7 @@
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists'; import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
import { clearFormatting } from '$lib/editor/clearFormatting'; import { clearFormatting } from '$lib/editor/clearFormatting';
import { serializeInlineMarkdown } from '$lib/editor/markdown'; import { serializeInlineMarkdown } from '$lib/editor/markdown';
import { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility';
import { replaceWithWikiLink } from '$lib/editor/wikiLinks'; import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths'; import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
import GraphView from './GraphView.svelte'; import GraphView from './GraphView.svelte';
@@ -117,9 +118,7 @@
let hasPendingBlobs = false; let hasPendingBlobs = false;
let lastSourceMode = $sourceMode; let lastSourceMode = $sourceMode;
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null); let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
let titleWasStripped = false; let hiddenTitleHeading: HiddenTitleHeading | null = null;
let strippedTitle = '';
let strippedHeadingPrefix = '';
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null; let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
let taskRevealElement: HTMLElement | null = null; let taskRevealElement: HTMLElement | null = null;
let taskRevealRequest = 0; let taskRevealRequest = 0;
@@ -3403,44 +3402,13 @@
} }
function stripTitleH1(md: string): string { function stripTitleH1(md: string): string {
const title = $activeNote?.meta.title; const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false);
if (!$appConfig?.hide_title_in_body || !title) { hiddenTitleHeading = result.hiddenTitle;
titleWasStripped = false; return result.markdown;
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;
} }
function restoreTitleH1(md: string): string { function restoreTitleH1(md: string): string {
if (!titleWasStripped || !strippedTitle) return md; return restoreTitleHeading(md, hiddenTitleHeading);
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
} }
function editorToMarkdown(): string { function editorToMarkdown(): string {
@@ -5794,7 +5762,7 @@
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length ? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
: 0; : 0;
const docNonWs = docNonWhitespace(); const docNonWs = docNonWhitespace();
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? ''); sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? ''));
resetSourceHistory(sourceContent); resetSourceHistory(sourceContent);
lastSourceMode = true; lastSourceMode = true;
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0; const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
@@ -5821,20 +5789,20 @@
}; };
if (isMobile) { if (isMobile) {
// Mobile: editor stays in DOM, just update its content // Mobile: editor stays in DOM, just update its content
const content = srcText || ($activeNote?.content ?? ''); const content = srcText;
if (editor) { if (editor) {
ignoreNextUpdate = true; ignoreNextUpdate = true;
editor.commands.setContent(markdownToHtml(content)); editor.commands.setContent(markdownToHtml(restoreTitleH1(content)));
tick().then(restoreRichCaret); tick().then(restoreRichCaret);
} }
} else { } else {
// Desktop: destroy old editor (its DOM element is gone), // Desktop: destroy old editor (its DOM element is gone),
// wait for DOM to swap textarea→div, then create editor on new element. // wait for DOM to swap textarea→div, then create editor on new element.
destroyEditor(); destroyEditor();
const content = srcText || ($activeNote?.content ?? ''); const content = srcText;
tick().then(() => { tick().then(() => {
if (editorElement && !editor) { if (editorElement && !editor) {
createEditor(content); createEditor(restoreTitleH1(content));
restoreRichCaret(); restoreRichCaret();
} }
}); });
@@ -5967,7 +5935,7 @@
const oldPath = $activeNotePath; const oldPath = $activeNotePath;
$activeNote.meta.title = newTitle; $activeNote.meta.title = newTitle;
// Update stripped title so restoreTitleH1 uses the new title // Update stripped title so restoreTitleH1 uses the new title
if (titleWasStripped) strippedTitle = newTitle; if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle };
$editorDirty = true; $editorDirty = true;
// Force save current editor content before renaming so disk is up-to-date // Force save current editor content before renaming so disk is up-to-date
await forceSave(); await forceSave();
+46
View File
@@ -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}`;
}
+104
View File
@@ -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
});
});