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:
lucaronin
2026-04-06 13:08:17 +02:00
parent 0b58fd6061
commit d50f3479dc
18 changed files with 195 additions and 71 deletions

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 () => {