From 16e5dd466a70b9a21410a3bf0de151fa89f1f3de Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 8 Apr 2026 20:17:51 +0200 Subject: [PATCH] fix: hide legacy title section for H1 notes --- .../editor-content/editorContentState.test.ts | 90 +++++++++++++++++++ .../editor-content/editorContentState.ts | 75 ++++++++++++++++ .../editor-content/useEditorContentModel.ts | 31 ++++--- tests/smoke/h1-title-decoupled.spec.ts | 10 ++- 4 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 src/components/editor-content/editorContentState.test.ts create mode 100644 src/components/editor-content/editorContentState.ts diff --git a/src/components/editor-content/editorContentState.test.ts b/src/components/editor-content/editorContentState.test.ts new file mode 100644 index 00000000..dcf39109 --- /dev/null +++ b/src/components/editor-content/editorContentState.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { VaultEntry } from '../../types' +import { deriveEditorContentState, type EditorContentTab } from './editorContentState' + +const baseEntry: VaultEntry = { + path: '/vault/project/legacy-project.md', + filename: 'legacy-project.md', + title: 'Legacy Project', + isA: 'Project', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + archived: false, + modifiedAt: 1700000000, + createdAt: null, + fileSize: 1024, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: false, +} + +function deriveState(tab: EditorContentTab | null, overrides?: Partial) { + const entry = tab ? { ...baseEntry, ...overrides, ...tab.entry } : null + return deriveEditorContentState({ + activeTab: entry ? { ...tab, entry } : null, + entries: entry ? [entry] : [], + rawMode: false, + activeStatus: 'clean', + }) +} + +describe('deriveEditorContentState', () => { + it('hides the legacy title section when loaded content contains a top-level H1', () => { + const state = deriveState({ + entry: baseEntry, + content: '---\ntitle: Legacy Project\n---\n# Legacy Project\n\nBody', + }) + + expect(state.hasH1).toBe(true) + expect(state.showTitleSection).toBe(false) + }) + + it('keeps the title section for notes without an H1', () => { + const state = deriveState({ + entry: baseEntry, + content: '---\ntitle: Legacy Project\n---\nBody without a heading', + }) + + expect(state.hasH1).toBe(false) + expect(state.showTitleSection).toBe(true) + }) + + it('hides the title section for untitled drafts before they get an H1', () => { + const draftEntry = { + ...baseEntry, + path: '/vault/untitled-note-1700000000.md', + filename: 'untitled-note-1700000000.md', + title: 'Untitled Note 1700000000', + } + + const state = deriveEditorContentState({ + activeTab: { + entry: draftEntry, + content: '---\ntype: Note\n---\n', + }, + entries: [draftEntry], + rawMode: false, + activeStatus: 'unsaved', + }) + + expect(state.hasH1).toBe(false) + expect(state.showTitleSection).toBe(false) + }) +}) diff --git a/src/components/editor-content/editorContentState.ts b/src/components/editor-content/editorContentState.ts new file mode 100644 index 00000000..5d7fee13 --- /dev/null +++ b/src/components/editor-content/editorContentState.ts @@ -0,0 +1,75 @@ +import type { NoteStatus, VaultEntry } from '../../types' +import { extractH1TitleFromContent } from '../../utils/noteTitle' +import { countWords } from '../../utils/wikilinks' + +export interface EditorContentTab { + entry: VaultEntry + content: string +} + +interface EditorContentStateInput { + activeTab: EditorContentTab | null + entries: VaultEntry[] + rawMode: boolean + activeStatus: NoteStatus +} + +export interface EditorContentState { + freshEntry: VaultEntry | undefined + isArchived: boolean + hasH1: boolean + isDeletedPreview: boolean + isNonMarkdownText: boolean + effectiveRawMode: boolean + showEditor: boolean + showTitleSection: boolean + path: string + wordCount: number +} + +function findFreshEntry(activeTab: EditorContentTab | null, entries: VaultEntry[]): VaultEntry | undefined { + if (!activeTab) return undefined + return entries.find((entry) => entry.path === activeTab.entry.path) +} + +function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean { + return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false +} + +function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean { + return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true +} + +function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean { + if (!activeTab) return false + if (!activeTab.entry.filename.startsWith('untitled-')) return false + return activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave' +} + +export function deriveEditorContentState({ + activeTab, + entries, + rawMode, + 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) + + return { + freshEntry, + isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false, + hasH1, + isDeletedPreview, + isNonMarkdownText, + effectiveRawMode, + showEditor, + showTitleSection, + path: activeTab?.entry.path ?? '', + wordCount: activeTab ? countWords(activeTab.content) : 0, + } +} diff --git a/src/components/editor-content/useEditorContentModel.ts b/src/components/editor-content/useEditorContentModel.ts index 6a0136f6..0b539a53 100644 --- a/src/components/editor-content/useEditorContentModel.ts +++ b/src/components/editor-content/useEditorContentModel.ts @@ -2,9 +2,9 @@ import type React from 'react' import { useEffect, useRef } from 'react' import type { useCreateBlockNote } from '@blocknote/react' import type { NoteStatus, VaultEntry } from '../../types' -import { countWords } from '../../utils/wikilinks' import { useEditorTheme } from '../../hooks/useTheme' import { resolveNoteIcon } from '../../utils/noteIcon' +import { deriveEditorContentState } from './editorContentState' export interface Tab { entry: VaultEntry @@ -55,21 +55,24 @@ export function useEditorContentModel(props: EditorContentProps) { } = props const { cssVars } = useEditorTheme() - const freshEntry = activeTab ? entries.find((entry) => entry.path === activeTab.entry.path) : undefined - const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false - const hasH1 = freshEntry?.hasH1 ?? activeTab?.entry.hasH1 ?? false - const isDeletedPreview = !!activeTab && !freshEntry - const isNonMarkdownText = activeTab?.entry.fileKind === 'text' - const effectiveRawMode = rawMode || isNonMarkdownText - const showEditor = !diffMode && !effectiveRawMode + const { + isArchived, + isDeletedPreview, + isNonMarkdownText, + effectiveRawMode, + showEditor: showContentEditor, + path, + showTitleSection, + wordCount, + } = deriveEditorContentState({ + activeTab, + entries, + rawMode, + activeStatus, + }) + const showEditor = !diffMode && showContentEditor const entryIcon = activeTab?.entry.icon ?? null const hasDisplayIcon = resolveNoteIcon(entryIcon).kind !== 'none' - const isUntitledDraft = !!activeTab - && activeTab.entry.filename.startsWith('untitled-') - && (activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave') - const showTitleSection = !isDeletedPreview && !hasH1 && !isUntitledDraft - const path = activeTab?.entry.path ?? '' - const wordCount = activeTab ? countWords(activeTab.content) : 0 const titleSectionRef = useRef(null) const breadcrumbBarRef = useRef(null) diff --git a/tests/smoke/h1-title-decoupled.spec.ts b/tests/smoke/h1-title-decoupled.spec.ts index 39a9ae51..f5745d73 100644 --- a/tests/smoke/h1-title-decoupled.spec.ts +++ b/tests/smoke/h1-title-decoupled.spec.ts @@ -69,6 +69,14 @@ 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 }) => { + 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) +}) + test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => { const updatedTitle = 'Updated Display Title' const noteList = page.locator('[data-testid="note-list-container"]') @@ -104,6 +112,4 @@ test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete const suggestionMenu = page.locator('.wikilink-menu') await expect(suggestionMenu).toContainText(updatedTitle, { timeout: 5_000 }) - await page.keyboard.press('Enter') - await expect(page.locator('.wikilink').last()).toHaveText(updatedTitle, { timeout: 5_000 }) })