Compare commits

...

2 Commits

Author SHA1 Message Date
Test
b9879b15c9 fix: keep note list icons inline 2026-04-09 13:01:28 +02:00
Test
32a30b40b9 fix: hide legacy title chrome for frontmatter titles 2026-04-09 12:51:06 +02:00
9 changed files with 244 additions and 58 deletions

View File

@@ -10,18 +10,20 @@ describe('NoteTitleIcon', () => {
})
it('renders a Phosphor icon when the name is recognized', () => {
render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" />)
const { container } = render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" className="mr-1" />)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('svg')
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
})
it('renders an image when the icon is an http url', () => {
render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" />)
const { container } = render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" className="mr-1" />)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('img')
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
})
it('renders nothing for an unrecognized icon value', () => {

View File

@@ -9,6 +9,21 @@ interface NoteTitleIconProps {
testId?: string
}
function IconWrapper({
children,
className,
size,
}: Pick<NoteTitleIconProps, 'className' | 'size'> & { children: React.ReactNode }) {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center align-middle', className)}
style={{ width: size, height: size, lineHeight: 1 }}
>
{children}
</span>
)
}
export function NoteTitleIcon({ icon, size = 14, className, color, testId }: NoteTitleIconProps) {
const resolved = resolveNoteIcon(icon)
@@ -16,39 +31,40 @@ export function NoteTitleIcon({ icon, size = 14, className, color, testId }: Not
if (resolved.kind === 'emoji') {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', className)}
style={{ fontSize: size, lineHeight: 1 }}
data-testid={testId}
>
{resolved.value}
</span>
<IconWrapper className={className} size={size}>
<span style={{ fontSize: size, lineHeight: 1 }} data-testid={testId}>
{resolved.value}
</span>
</IconWrapper>
)
}
if (resolved.kind === 'image') {
return (
<img
src={resolved.src}
alt=""
aria-hidden="true"
className={cn('shrink-0 rounded-sm object-cover', className)}
style={{ width: size, height: size }}
onError={(event) => {
event.currentTarget.style.display = 'none'
}}
data-testid={testId}
/>
<IconWrapper className={className} size={size}>
<img
src={resolved.src}
alt=""
aria-hidden="true"
className="block h-full w-full rounded-sm object-cover"
onError={(event) => {
event.currentTarget.style.display = 'none'
}}
data-testid={testId}
/>
</IconWrapper>
)
}
return (
<resolved.Icon
width={size}
height={size}
className={cn('shrink-0', className)}
style={color ? { color } : undefined}
data-testid={testId}
/>
<IconWrapper className={className} size={size}>
<resolved.Icon
width={size}
height={size}
className="block"
style={color ? { color } : undefined}
data-testid={testId}
/>
</IconWrapper>
)
}

View File

@@ -62,6 +62,26 @@ describe('deriveEditorContentState', () => {
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
})
it('hides the legacy title section when a frontmatter title drives the display title', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
})
it('keeps the title section when the document title still comes from the filename', () => {
const state = deriveState({
entry: baseEntry,
content: '---\nstatus: Active\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(true)
})

View File

@@ -1,5 +1,5 @@
import type { NoteStatus, VaultEntry } from '../../types'
import { extractH1TitleFromContent } from '../../utils/noteTitle'
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
import { countWords } from '../../utils/wikilinks'
export interface EditorContentTab {
@@ -14,6 +14,19 @@ interface EditorContentStateInput {
activeStatus: NoteStatus
}
interface TitleSectionState {
hasDisplayTitle: boolean
hasH1: boolean
}
interface VisibilityState {
effectiveRawMode: boolean
isDeletedPreview: boolean
isNonMarkdownText: boolean
showEditor: boolean
showTitleSection: boolean
}
export interface EditorContentState {
freshEntry: VaultEntry | undefined
isArchived: boolean
@@ -36,10 +49,53 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
}
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
}
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
}
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
return hasH1 || contentDefinesTitle(activeTab)
}
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
const hasH1 = resolveHasH1(activeTab, freshEntry)
return {
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
hasH1,
}
}
function deriveVisibilityState(input: {
activeStatus: NoteStatus
activeTab: EditorContentTab | null
freshEntry: VaultEntry | undefined
hasDisplayTitle: boolean
rawMode: boolean
}): VisibilityState {
const {
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle,
rawMode,
} = input
const isDeletedPreview = !!activeTab && !freshEntry
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
const effectiveRawMode = rawMode || isNonMarkdownText
return {
isDeletedPreview,
isNonMarkdownText,
effectiveRawMode,
showEditor: !effectiveRawMode,
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
}
}
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
if (!activeTab) return false
if (!activeTab.entry.filename.startsWith('untitled-')) return false
@@ -53,22 +109,20 @@ export function deriveEditorContentState({
activeStatus,
}: EditorContentStateInput): EditorContentState {
const freshEntry = findFreshEntry(activeTab, entries)
const isDeletedPreview = !!activeTab && !freshEntry
const hasH1 = resolveHasH1(activeTab, freshEntry)
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
const effectiveRawMode = rawMode || isNonMarkdownText
const showEditor = !effectiveRawMode
const showTitleSection = !isDeletedPreview && !hasH1 && !isUnsavedUntitledDraft(activeTab, activeStatus)
const titleState = deriveTitleSectionState(activeTab, freshEntry)
const visibilityState = deriveVisibilityState({
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle: titleState.hasDisplayTitle,
rawMode,
})
return {
freshEntry,
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
hasH1,
isDeletedPreview,
isNonMarkdownText,
effectiveRawMode,
showEditor,
showTitleSection,
hasH1: titleState.hasH1,
...visibilityState,
path: activeTab?.entry.path ?? '',
wordCount: activeTab ? countWords(activeTab.content) : 0,
}

View File

@@ -36,11 +36,11 @@ export function useEditorSaveWithLinks(config: {
const filename = path.split('/').pop() ?? path
const fmPatch = {
...frontmatterPatch,
...deriveDisplayTitleState(
...deriveDisplayTitleState({
content,
filename,
typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
),
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
}),
}
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { deriveDisplayTitleState, extractH1TitleFromContent, filenameStemToTitle } from './noteTitle'
import {
contentDefinesDisplayTitle,
deriveDisplayTitleState,
extractFrontmatterTitleFromContent,
extractH1TitleFromContent,
filenameStemToTitle,
} from './noteTitle'
describe('filenameStemToTitle', () => {
it('converts kebab-case filenames into title case', () => {
@@ -23,10 +29,32 @@ describe('extractH1TitleFromContent', () => {
})
})
describe('extractFrontmatterTitleFromContent', () => {
it('extracts the frontmatter title when present', () => {
const content = '---\ntitle: Legacy Title\nstatus: Active\n---\n## Body'
expect(extractFrontmatterTitleFromContent(content)).toBe('Legacy Title')
})
it('returns null when the frontmatter title is missing', () => {
expect(extractFrontmatterTitleFromContent('---\nstatus: Active\n---\n## Body')).toBeNull()
})
})
describe('contentDefinesDisplayTitle', () => {
it('returns true when the document title comes from frontmatter', () => {
const content = '---\ntitle: Spring 2026\n---\n## Goals'
expect(contentDefinesDisplayTitle(content)).toBe(true)
})
it('returns false when title still comes from the filename', () => {
expect(contentDefinesDisplayTitle('Body only')).toBe(false)
})
})
describe('deriveDisplayTitleState', () => {
it('prefers H1 over frontmatter title and filename', () => {
const content = '---\ntitle: Legacy Title\n---\n# Updated Title\n\nBody'
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
title: 'Updated Title',
hasH1: true,
})
@@ -34,14 +62,22 @@ describe('deriveDisplayTitleState', () => {
it('falls back to frontmatter title when no H1 is present', () => {
const content = '---\ntitle: Legacy Title\n---\nBody'
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
title: 'Legacy Title',
hasH1: false,
})
})
it('reads the frontmatter title from content when no explicit title is passed', () => {
const content = '---\ntitle: Spring 2026\n---\n## Goals'
expect(deriveDisplayTitleState({ content, filename: 'spring-2026.md' })).toEqual({
title: 'Spring 2026',
hasH1: false,
})
})
it('falls back to filename title when there is no H1 or frontmatter title', () => {
expect(deriveDisplayTitleState('Body only', 'renamed-note.md')).toEqual({
expect(deriveDisplayTitleState({ content: 'Body only', filename: 'renamed-note.md' })).toEqual({
title: 'Renamed Note',
hasH1: false,
})

View File

@@ -1,5 +1,22 @@
import { parseFrontmatter } from './frontmatter'
import { splitFrontmatter } from './wikilinks'
interface ResolvedContentTitle {
source: 'h1' | 'frontmatter'
title: string
}
interface DisplayTitleInput {
content: string
filename: string
frontmatterTitle?: string | null
}
interface DisplayTitleState {
title: string
hasH1: boolean
}
function replaceWikilinkAliases(text: string): string {
return text.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
}
@@ -49,20 +66,46 @@ export function extractH1TitleFromContent(content: string): string | null {
return null
}
export function deriveDisplayTitleState(
content: string,
filename: string,
frontmatterTitle?: string | null,
): { title: string, hasH1: boolean } {
export function extractFrontmatterTitleFromContent(content: string): string | null {
const title = parseFrontmatter(content).title
if (typeof title !== 'string') return null
const trimmed = title.trim()
return trimmed || null
}
function resolveContentTitle(content: string, frontmatterTitle?: string | null): ResolvedContentTitle | null {
const h1Title = extractH1TitleFromContent(content)
if (h1Title) {
return { title: h1Title, hasH1: true }
return { title: h1Title, source: 'h1' }
}
const trimmedFrontmatterTitle = frontmatterTitle?.trim()
if (trimmedFrontmatterTitle) {
return { title: trimmedFrontmatterTitle, hasH1: false }
const resolvedFrontmatterTitle = frontmatterTitle?.trim() || extractFrontmatterTitleFromContent(content)
if (resolvedFrontmatterTitle) {
return { title: resolvedFrontmatterTitle, source: 'frontmatter' }
}
return { title: filenameStemToTitle(filename), hasH1: false }
return null
}
export function contentDefinesDisplayTitle(content: string): boolean {
return resolveContentTitle(content) !== null
}
export function deriveDisplayTitleState({
content,
filename,
frontmatterTitle,
}: DisplayTitleInput): DisplayTitleState {
const resolvedTitle = resolveContentTitle(content, frontmatterTitle)
if (resolvedTitle) {
return {
title: resolvedTitle.title,
hasH1: resolvedTitle.source === 'h1',
}
}
return {
title: filenameStemToTitle(filename),
hasH1: false,
}
}

View File

@@ -0,0 +1,10 @@
---
title: Spring 2026
Is A: Quarter
Status: Active
---
## Goals
- [[Alpha Project]]
- [[Note B]]

View File

@@ -69,12 +69,17 @@ test('creating an untitled draft hides the legacy title section in the editor',
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
})
test('@smoke older notes with an H1 do not render the legacy title section', async ({ page }) => {
test('@smoke older notes with a document title do not render the legacy title section', async ({ page }) => {
await openNote(page, 'Alpha Project')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
await openNote(page, 'Spring 2026')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
})
test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {