feat: H1-as-title — title resolution, TitleField hiding, breadcrumb filename

- Rust: extract_title now prioritizes H1 > frontmatter > filename
- Rust: add has_h1 field to VaultEntry for frontend TitleField control
- Frontend: hide TitleField + icon picker when note has H1 in body
- Frontend: breadcrumb shows filename stem instead of display title
- Frontend: new notes use untitled-{type}-{timestamp}.md, no title in frontmatter
- CSS: only hide first H1 in BlockNote when TitleField is visible
- Updated all tests for new title resolution and note creation behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-06 13:08:17 +02:00
parent 901326d06f
commit 377a3f8d0e
18 changed files with 195 additions and 71 deletions

View File

@@ -1,5 +1,4 @@
---
title: Untitled note 358
type: Note
status: Active
---

View File

@@ -0,0 +1,4 @@
---
type: Note
status: Active
---

View File

@@ -0,0 +1,4 @@
---
type: Note
status: Active
---

View File

@@ -0,0 +1,4 @@
---
type: Note
status: Active
---

View File

@@ -0,0 +1,5 @@
---
type: Note
status: Active
---
My Custom H1 Heading

View File

@@ -0,0 +1,5 @@
---
title: Untitled note 361
type: Note
status: Active
---

View File

@@ -78,6 +78,10 @@ pub struct VaultEntry {
/// Configured via `_list_properties_display` in the type file's frontmatter.
#[serde(rename = "listPropertiesDisplay", default)]
pub list_properties_display: Vec<String>,
/// Whether the note body has an H1 heading on the first non-empty line.
/// Used by the frontend to decide whether to show the TitleField.
#[serde(rename = "hasH1")]
pub has_h1: bool,
/// File kind: "markdown", "text", or "binary".
/// Determines how the frontend renders and opens the file.
#[serde(rename = "fileKind", default = "default_file_kind")]

View File

@@ -57,6 +57,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
let has_h1 = parsing::extract_h1_title(&content).is_some();
let snippet = extract_snippet(&content);
let word_count = count_body_words(&content);
let outgoing_links = extract_outgoing_links(&parsed.content);
@@ -116,6 +117,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
word_count,
outgoing_links,
properties,
has_h1,
file_kind: "markdown".to_string(),
})
}

View File

@@ -21,15 +21,40 @@ pub(super) fn slug_to_title(stem: &str) -> String {
.join(" ")
}
/// Extract the H1 title from the first non-empty line of the body (after frontmatter).
/// Returns `None` if no H1 is found on the first non-empty line.
pub(super) fn extract_h1_title(content: &str) -> Option<String> {
let body = strip_frontmatter(content);
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(title) = trimmed.strip_prefix("# ") {
let title = strip_markdown_chars(title).trim().to_string();
if !title.is_empty() {
return Some(title);
}
}
break; // first non-empty line is not H1
}
None
}
/// Extract the display title for a note.
/// Priority: frontmatter `title:` → filename-derived title.
/// H1 headings are treated as body content, not title source.
pub(super) fn extract_title(fm_title: Option<&str>, _content: &str, filename: &str) -> String {
/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title.
pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String {
// 1. H1 on first non-empty line of body
if let Some(h1) = extract_h1_title(content) {
return h1;
}
// 2. frontmatter title (legacy, backward compat)
if let Some(title) = fm_title {
if !title.is_empty() {
return title.to_string();
}
}
// 3. filename slug
let stem = filename.strip_suffix(".md").unwrap_or(filename);
slug_to_title(stem)
}

View File

@@ -12,8 +12,6 @@ const baseEntry: VaultEntry = {
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
@@ -24,6 +22,18 @@ const baseEntry: VaultEntry = {
icon: null,
color: null,
order: null,
outgoingLinks: [],
template: null,
sort: null,
sidebarLabel: null,
view: null,
visible: null,
properties: {},
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
hasH1: false,
}
const archivedEntry: VaultEntry = {
@@ -94,13 +104,14 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(screen.getByText('Note')).toBeInTheDocument()
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByText('Test Note')).toBeInTheDocument()
expect(screen.getByText('test')).toBeInTheDocument()
})
it('shows emoji icon when entry has an emoji icon', () => {
it('does not render emoji icon in breadcrumb (icon removed from breadcrumb title)', () => {
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
expect(screen.getByText('🚀')).toBeInTheDocument()
// BreadcrumbTitle now only shows type label and filename stem — no icon
expect(screen.queryByText('🚀')).not.toBeInTheDocument()
})
it('does not show icon when entry has a non-emoji icon', () => {

View File

@@ -179,14 +179,12 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
const typeLabel = entry.isA ?? 'Note'
const icon = entry.icon
const emojiIcon = icon && /^\p{Emoji}/u.test(icon) ? icon : null
const filenameStem = entry.filename.replace(/\.md$/, '')
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
{emojiIcon && <span className="shrink-0">{emojiIcon}</span>}
<span className="truncate font-medium text-foreground">{entry.title}</span>
<span className="truncate font-medium text-foreground">{filenameStem}</span>
</div>
)
}

View File

@@ -361,12 +361,13 @@
margin-top: 2px;
}
/* Hide the first H1 heading in BlockNote — title is shown in TitleField above */
.editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
/* When TitleField is shown (no H1 in body), hide the first H1 heading in
BlockNote to avoid duplicate title display. When hasH1 is set, the
TitleField is hidden and the H1 in the editor serves as the title. */
.title-section:not([data-has-h1]) ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
display: none;
}
/* Also hide the BlockContainer wrapper if its heading is hidden */
.editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
.title-section:not([data-has-h1]) ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
display: none;
}

View File

@@ -63,8 +63,6 @@ const mockEntry: VaultEntry = {
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: 'Luca',
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
@@ -74,9 +72,18 @@ const mockEntry: VaultEntry = {
relationships: {},
icon: null,
color: null,
order: null,
order: null,
template: null, sort: null,
outgoingLinks: [],
sidebarLabel: null,
view: null,
visible: null,
properties: {},
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
hasH1: false,
}
const mockContent = `---

View File

@@ -166,6 +166,7 @@ export function EditorContent({
const { cssVars } = useEditorTheme()
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
const hasH1 = freshEntry?.hasH1 ?? activeTab?.entry.hasH1 ?? false
// Non-markdown text files always use the raw editor (no BlockNote)
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
const effectiveRawMode = rawMode || isNonMarkdownText
@@ -232,26 +233,30 @@ export function EditorContent({
{showEditor && (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<div ref={titleSectionRef} className="title-section">
{!emojiIcon && (
<div className="title-section__add-icon">
<NoteIcon icon={null} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
</div>
<div ref={titleSectionRef} className="title-section" data-has-h1={hasH1 || undefined}>
{!hasH1 && (
<>
{!emojiIcon && (
<div className="title-section__add-icon">
<NoteIcon icon={null} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
</div>
)}
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
{emojiIcon && (
<NoteIcon icon={emojiIcon} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
)}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
<div className="title-section__separator" />
</>
)}
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
{emojiIcon && (
<NoteIcon icon={emojiIcon} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
)}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
<div className="title-section__separator" />
</div>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable />
</div>

View File

@@ -57,7 +57,10 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
color: null,
order: null,
outgoingLinks: [],
template: null, sort: null,
template: null, sort: null, sidebarLabel: null,
view: null, visible: null, properties: {}, organized: false,
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
hasH1: false,
...overrides,
})
@@ -212,11 +215,16 @@ describe('entryMatchesTarget', () => {
})
describe('buildNoteContent', () => {
it('generates frontmatter with status for regular types', () => {
it('generates frontmatter with title and status for regular types', () => {
const content = buildNoteContent('My Note', 'Note', 'Active')
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
})
it('omits title when null', () => {
const content = buildNoteContent(null, 'Note', 'Active')
expect(content).toBe('---\ntype: Note\nstatus: Active\n---\n')
})
it('omits status when null', () => {
const content = buildNoteContent('AI', 'Topic', null)
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n')
@@ -701,7 +709,8 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
it('handleCreateNoteImmediate creates note with auto-generated title', () => {
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
@@ -710,11 +719,15 @@ describe('useNoteActions hook', () => {
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.title).toBe('Untitled note')
expect(createdEntry.title).toBe('Untitled Note 1700000000')
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
expect(createdEntry.isA).toBe('Note')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate generates unique names on rapid calls', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
@@ -724,13 +737,17 @@ describe('useNoteActions hook', () => {
})
expect(addEntry).toHaveBeenCalledTimes(3)
const titles = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.title)
expect(titles[0]).toBe('Untitled note')
expect(titles[1]).toBe('Untitled note 2')
expect(titles[2]).toBe('Untitled note 3')
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
// Each call consumes Date.now() multiple times, so just verify uniqueness and pattern
expect(new Set(filenames).size).toBe(3)
for (const fn of filenames) {
expect(fn).toMatch(/^untitled-note-\d+\.md$/)
}
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
@@ -739,8 +756,9 @@ describe('useNoteActions hook', () => {
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.title).toBe('Untitled project')
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
expect(createdEntry.isA).toBe('Project')
vi.restoreAllMocks()
})
it('handleCreateNote uses default template for Project type', () => {
@@ -918,8 +936,8 @@ describe('useNoteActions hook', () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'), expect.stringContaining('Untitled note'))
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/), expect.stringContaining('type: Note'))
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {

View File

@@ -36,7 +36,8 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '',
wordCount: 0, relationships: {}, icon: null, color: null, order: null,
outgoingLinks: [], template: null, sort: null, sidebarLabel: null,
view: null, visible: null, properties: {},
view: null, visible: null, properties: {}, organized: false, favorite: false,
favoriteIndex: null, listPropertiesDisplay: [], hasH1: false,
...overrides,
})
@@ -114,10 +115,14 @@ describe('entryMatchesTarget', () => {
})
describe('buildNoteContent', () => {
it('generates frontmatter with status', () => {
it('generates frontmatter with title and status', () => {
expect(buildNoteContent('My Note', 'Note', 'Active')).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
})
it('omits title when null', () => {
expect(buildNoteContent(null, 'Note', 'Active')).toBe('---\ntype: Note\nstatus: Active\n---\n')
})
it('omits status when null', () => {
expect(buildNoteContent('AI', 'Topic', null)).toBe('---\ntitle: AI\ntype: Topic\n---\n')
})
@@ -233,22 +238,32 @@ describe('useNoteCreation hook', () => {
expect(createdEntry.isA).toBe('Note')
})
it('handleCreateNoteImmediate generates untitled name', () => {
it('handleCreateNoteImmediate generates timestamp-based title', () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
expect(addEntry).toHaveBeenCalledTimes(1)
expect(addEntry.mock.calls[0][0].title).toBe('Untitled note')
expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000')
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate generates unique names on rapid calls', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
})
const titles = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.title)
expect(titles).toEqual(['Untitled note', 'Untitled note 2', 'Untitled note 3'])
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
// Each call consumes Date.now() multiple times (filename + buildNewEntry), so just verify uniqueness
expect(new Set(filenames).size).toBe(3)
for (const fn of filenames) {
expect(fn).toMatch(/^untitled-note-\d+\.md$/)
}
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
@@ -265,7 +280,7 @@ describe('useNoteCreation hook', () => {
config.markContentPending = markContentPending
const { result } = renderHook(() => useNoteCreation(config, tabDeps))
await act(async () => { result.current.handleCreateNoteImmediate() })
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'))
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalled()
})

View File

@@ -20,7 +20,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, archived: false,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], hasH1: false,
}
}
@@ -29,6 +29,11 @@ export function slugify(text: string): string {
return result || 'untitled'
}
/** Convert a filename slug to a human-readable title (hyphens → spaces, title case). */
function slug_to_title(slug: string): string {
return slug.split('-').filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
}
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): string {
const baseName = `Untitled ${type.toLowerCase()}`
@@ -63,8 +68,10 @@ export function resolveTemplate(entries: VaultEntry[], typeName: string): string
return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null
}
export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string {
const lines = ['---', `title: ${title}`, `type: ${type}`]
export function buildNoteContent(title: string | null, type: string, status: string | null, template?: string | null): string {
const lines = ['---']
if (title) lines.push(`title: ${title}`)
lines.push(`type: ${type}`)
if (status) lines.push(`status: ${status}`)
lines.push('---')
const body = template ? `\n${template}` : ''
@@ -168,19 +175,27 @@ interface ImmediateCreateDeps {
markContentPending?: (path: string, content: string) => void
}
/** Generate a unique untitled filename using a timestamp. */
function generateUntitledFilename(type: string): string {
const ts = Math.floor(Date.now() / 1000)
const prefix = type === 'Note' ? 'untitled-note' : `untitled-${type.toLowerCase()}`
return `${prefix}-${ts}`
}
/** Create an untitled note without persisting to disk (deferred save). */
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const noteType = type || 'Note'
const title = generateUntitledName(deps.entries, noteType, deps.pendingNames)
deps.pendingNames.add(title)
const slug = generateUntitledFilename(noteType)
const title = slug_to_title(slug)
const template = resolveTemplate(deps.entries, noteType)
const resolved = resolveNewNote(title, noteType, deps.vaultPath, template)
deps.openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
deps.trackUnsaved?.(resolved.entry.path)
deps.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor({ selectTitle: true })
setTimeout(() => deps.pendingNames.delete(title), 500)
const status = NO_STATUS_TYPES.has(noteType) ? null : 'Active'
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent(null, noteType, status, template)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor()
}
interface RelationshipCreateDeps {

View File

@@ -45,6 +45,8 @@ export interface VaultEntry {
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
properties: Record<string, string | number | boolean | null>
/** Whether the note body has an H1 heading on the first non-empty line. */
hasH1: boolean
/** File kind: "markdown", "text", or "binary". Determines editor behavior.
* Defaults to "markdown" when absent (for backwards compatibility). */
fileKind?: 'markdown' | 'text' | 'binary'