From 4701485ee5cae6c8a23c4483f3f8cfdca2989010 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 24 Apr 2026 20:13:34 +0200 Subject: [PATCH] fix: keep empty title clicks in h1 --- src/components/SingleEditorView.test.tsx | 40 ++++++++++ src/components/SingleEditorView.tsx | 15 +++- .../smoke/new-note-title-caret-click.spec.ts | 76 +++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 tests/smoke/new-note-title-caret-click.spec.ts 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') +})