fix: keep empty title clicks in h1

This commit is contained in:
lucaronin
2026-04-24 20:13:34 +02:00
parent 135a8a0adf
commit 4701485ee5
3 changed files with 128 additions and 3 deletions

View File

@@ -334,4 +334,44 @@ describe('SingleEditorView', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('routes clicks on the empty title wrapper back into the H1 block', async () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const titleBlockOuter = document.createElement('div')
titleBlockOuter.className = 'bn-block-outer'
const titleBlock = document.createElement('div')
titleBlock.className = 'bn-block'
const titleHeading = document.createElement('div')
titleHeading.setAttribute('data-content-type', 'heading')
titleHeading.setAttribute('data-level', '1')
const inlineHeading = document.createElement('div')
inlineHeading.className = 'bn-inline-content'
titleHeading.appendChild(inlineHeading)
titleBlock.appendChild(titleHeading)
titleBlockOuter.appendChild(titleBlock)
container?.appendChild(titleBlockOuter)
fireEvent.click(titleBlockOuter)
await act(async () => {
await Promise.resolve()
})
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('heading-block', 'end')
expect(editor.focus).toHaveBeenCalled()
})
})

View File

@@ -141,13 +141,22 @@ function isSelectionInsideElement(element: HTMLElement): boolean {
return Boolean(anchorElement && element.contains(anchorElement))
}
const TITLE_HEADING_SELECTOR = 'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])'
const TITLE_HEADING_WRAPPER_SELECTOR = '.bn-block-outer, .bn-block'
function findTitleHeadingElement(target: HTMLElement): HTMLElement | null {
const directHeading = target.closest<HTMLElement>(TITLE_HEADING_SELECTOR)
if (directHeading) return directHeading
const titleWrapper = target.closest<HTMLElement>(TITLE_HEADING_WRAPPER_SELECTOR)
return titleWrapper?.querySelector<HTMLElement>(TITLE_HEADING_SELECTOR) ?? null
}
function queueTitleHeadingCursorRepair(
target: HTMLElement,
editor: ReturnType<typeof useCreateBlockNote>,
): boolean {
const titleHeading = target.closest<HTMLElement>(
'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])',
)
const titleHeading = findTitleHeadingElement(target)
if (!titleHeading) return false
queueMicrotask(() => {

View File

@@ -0,0 +1,76 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
async function createUntitledNote(page: Page): Promise<void> {
await page.locator('body').click()
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
timeout: 5_000,
})
}
async function clearEditorSelection(page: Page): Promise<void> {
await page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
active?.blur()
window.getSelection()?.removeAllRanges()
})
}
async function clickTitleWrapperPadding(page: Page): Promise<void> {
const clickTarget = await page.evaluate(() => {
const titleBlockOuter = document.querySelector('.bn-block-outer') as HTMLElement | null
if (!titleBlockOuter) return null
const rect = titleBlockOuter.getBoundingClientRect()
return {
x: rect.left + Math.min(32, Math.max(12, rect.width / 2)),
y: rect.bottom - Math.min(4, Math.max(1, rect.height / 3)),
}
})
expect(clickTarget).not.toBeNull()
await page.mouse.click(clickTarget!.x, clickTarget!.y)
}
async function activeSelectionBlockType(page: Page): Promise<string | null> {
return page.evaluate(() => {
const selection = window.getSelection()
const anchorNode = selection?.anchorNode ?? null
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
return anchorElement?.closest('.bn-block-content')?.getAttribute('data-content-type') ?? null
})
}
async function editorHasContentEditableFocus(page: Page): Promise<boolean> {
return page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
})
}
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultTauri(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke clicking an untitled title wrapper restores the caret to the H1', async ({ page }) => {
await createUntitledNote(page)
await clearEditorSelection(page)
await expect.poll(() => editorHasContentEditableFocus(page), { timeout: 5_000 }).toBe(false)
await clickTitleWrapperPadding(page)
await expect.poll(() => editorHasContentEditableFocus(page), { timeout: 5_000 }).toBe(true)
await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading')
})