diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx
index 1f270e61..4629d02b 100644
--- a/src/components/SingleEditorView.test.tsx
+++ b/src/components/SingleEditorView.test.tsx
@@ -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(
+ ,
+ )
+
+ 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()
+ })
})
diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx
index b6d34c42..4ab92376 100644
--- a/src/components/SingleEditorView.tsx
+++ b/src/components/SingleEditorView.tsx
@@ -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(TITLE_HEADING_SELECTOR)
+ if (directHeading) return directHeading
+
+ const titleWrapper = target.closest(TITLE_HEADING_WRAPPER_SELECTOR)
+ return titleWrapper?.querySelector(TITLE_HEADING_SELECTOR) ?? null
+}
+
function queueTitleHeadingCursorRepair(
target: HTMLElement,
editor: ReturnType,
): boolean {
- const titleHeading = target.closest(
- 'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])',
- )
+ const titleHeading = findTitleHeadingElement(target)
if (!titleHeading) return false
queueMicrotask(() => {
diff --git a/tests/smoke/new-note-title-caret-click.spec.ts b/tests/smoke/new-note-title-caret-click.spec.ts
new file mode 100644
index 00000000..d92689a0
--- /dev/null
+++ b/tests/smoke/new-note-title-caret-click.spec.ts
@@ -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 {
+ 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 {
+ await page.evaluate(() => {
+ const active = document.activeElement as HTMLElement | null
+ active?.blur()
+ window.getSelection()?.removeAllRanges()
+ })
+}
+
+async function clickTitleWrapperPadding(page: Page): Promise {
+ 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 {
+ 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 {
+ 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')
+})