diff --git a/blocksuite/affine/blocks/note/src/note-edgeless-block.ts b/blocksuite/affine/blocks/note/src/note-edgeless-block.ts
index bc2829e7a1..2f59756286 100644
--- a/blocksuite/affine/blocks/note/src/note-edgeless-block.ts
+++ b/blocksuite/affine/blocks/note/src/note-edgeless-block.ts
@@ -454,6 +454,28 @@ export const EdgelessNoteInteraction =
return;
}
+ let isClickOnTitle = false;
+ const titleRect = view
+ .querySelector('edgeless-page-block-title')
+ ?.getBoundingClientRect();
+
+ if (titleRect) {
+ const titleBound = new Bound(
+ titleRect.x,
+ titleRect.y,
+ titleRect.width,
+ titleRect.height
+ );
+ if (titleBound.isPointInBound([e.clientX, e.clientY])) {
+ isClickOnTitle = true;
+ }
+ }
+
+ if (isClickOnTitle) {
+ handleNativeRangeAtPoint(e.clientX, e.clientY);
+ return;
+ }
+
if (model.children.length === 0) {
const blockId = std.store.addBlock(
'affine:paragraph',
diff --git a/blocksuite/affine/shared/src/__tests__/utils/range.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/utils/range.unit.spec.ts
new file mode 100644
index 0000000000..82d1058e52
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/utils/range.unit.spec.ts
@@ -0,0 +1,108 @@
+import {
+ beforeEach,
+ describe,
+ expect,
+ it,
+ type MockInstance,
+ vi,
+} from 'vitest';
+
+import * as PointToRangeUtils from '../../utils/dom/point-to-range';
+import { handleNativeRangeAtPoint } from '../../utils/dom/point-to-range';
+
+describe('test handleNativeRangeAtPoint', () => {
+ let caretRangeFromPointSpy: MockInstance<
+ (clientX: number, clientY: number) => Range | null
+ >;
+ let resetNativeSelectionSpy: MockInstance<(range: Range | null) => void>;
+
+ beforeEach(() => {
+ caretRangeFromPointSpy = vi.spyOn(
+ PointToRangeUtils.api,
+ 'caretRangeFromPoint'
+ );
+ resetNativeSelectionSpy = vi.spyOn(
+ PointToRangeUtils.api,
+ 'resetNativeSelection'
+ );
+ });
+
+ it('does nothing if caretRangeFromPoint returns null', () => {
+ caretRangeFromPointSpy.mockReturnValue(null);
+
+ handleNativeRangeAtPoint(10, 10);
+ expect(resetNativeSelectionSpy).not.toHaveBeenCalled();
+ });
+
+ it('keeps range untouched if startContainer is a Text node', () => {
+ const div = document.createElement('div');
+ div.textContent = 'hello';
+
+ const text = div.firstChild!;
+
+ const range = document.createRange();
+ range.setStart(text, 2);
+ range.collapse(true);
+
+ caretRangeFromPointSpy.mockReturnValue(range);
+
+ handleNativeRangeAtPoint(10, 10);
+
+ expect(range.startContainer).toBe(text);
+ expect(range.startOffset).toBe(2);
+ expect(resetNativeSelectionSpy).toHaveBeenCalled();
+ });
+
+ it('moves caret into direct text child when clicking element', () => {
+ const div = document.createElement('div');
+ div.append('hello');
+
+ const range = document.createRange();
+ range.setStart(div, 1);
+ range.collapse(true);
+
+ caretRangeFromPointSpy.mockReturnValue(range);
+
+ handleNativeRangeAtPoint(10, 10);
+
+ expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
+ expect(range.startContainer.textContent).toBe('hello');
+ expect(range.startOffset).toBe(5);
+ expect(resetNativeSelectionSpy).toHaveBeenCalled();
+ });
+
+ it('moves caret to last meaningful text inside nested element', () => {
+ const div = document.createElement('div');
+ div.innerHTML = `abc`;
+
+ const range = document.createRange();
+ range.setStart(div, 2);
+ range.collapse(true);
+
+ caretRangeFromPointSpy.mockReturnValue(range);
+
+ handleNativeRangeAtPoint(10, 10);
+
+ expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
+ expect(range.startContainer.textContent).toBe('c');
+ expect(range.startOffset).toBe(1);
+ expect(resetNativeSelectionSpy).toHaveBeenCalled();
+ });
+
+ it('falls back to searching startContainer when offset element has no text', () => {
+ const div = document.createElement('div');
+ div.innerHTML = `ok`;
+
+ const range = document.createRange();
+ range.setStart(div, 1);
+ range.collapse(true);
+
+ caretRangeFromPointSpy.mockReturnValue(range);
+
+ handleNativeRangeAtPoint(10, 10);
+
+ expect(range.startContainer.textContent).toBe('ok');
+ expect(range.startOffset).toBe(2);
+ expect(resetNativeSelectionSpy).toHaveBeenCalled();
+ });
+});
diff --git a/blocksuite/affine/shared/src/utils/dom/point-to-range.ts b/blocksuite/affine/shared/src/utils/dom/point-to-range.ts
index d7e879c844..1ec514f90d 100644
--- a/blocksuite/affine/shared/src/utils/dom/point-to-range.ts
+++ b/blocksuite/affine/shared/src/utils/dom/point-to-range.ts
@@ -88,11 +88,73 @@ export function getCurrentNativeRange(selection = window.getSelection()) {
return selection.getRangeAt(0);
}
+// functions need to be mocked in unit-test
+export const api = {
+ caretRangeFromPoint,
+ resetNativeSelection,
+};
+
export function handleNativeRangeAtPoint(x: number, y: number) {
- const range = caretRangeFromPoint(x, y);
+ const range = api.caretRangeFromPoint(x, y);
+ if (range) {
+ normalizeCaretRange(range);
+ }
+
const startContainer = range?.startContainer;
// click on rich text
if (startContainer instanceof Node) {
- resetNativeSelection(range);
+ api.resetNativeSelection(range);
+ }
+}
+
+function lastMeaningfulTextNode(node: Node) {
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
+ acceptNode(node) {
+ return node.textContent && node.textContent?.trim().length > 0
+ ? NodeFilter.FILTER_ACCEPT
+ : NodeFilter.FILTER_REJECT;
+ },
+ });
+
+ let last = null;
+ while (walker.nextNode()) {
+ last = walker.currentNode;
+ }
+ return last;
+}
+
+function normalizeCaretRange(range: Range) {
+ let { startContainer, startOffset } = range;
+ if (startContainer.nodeType === Node.TEXT_NODE) return;
+
+ // Try to find text in the element at `startOffset`
+ const offsetEl =
+ startOffset > 0
+ ? startContainer.childNodes[startOffset - 1]
+ : startContainer.childNodes[0];
+ if (offsetEl) {
+ if (offsetEl.nodeType === Node.TEXT_NODE) {
+ range.setStart(
+ offsetEl,
+ startOffset > 0 ? (offsetEl.textContent?.length ?? 0) : 0
+ );
+ range.collapse(true);
+ return;
+ }
+
+ const text = lastMeaningfulTextNode(offsetEl);
+ if (text) {
+ range.setStart(text, text.textContent?.length ?? 0);
+ range.collapse(true);
+ return;
+ }
+ }
+
+ // Fallback, try to find text in startContainer
+ const text = lastMeaningfulTextNode(startContainer);
+ if (text) {
+ range.setStart(text, text.textContent?.length ?? 0);
+ range.collapse(true);
+ return;
}
}
diff --git a/tests/affine-local/e2e/blocksuite/edgeless/embed.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/embed.spec.ts
index 0e7b5099a2..f86dcf3065 100644
--- a/tests/affine-local/e2e/blocksuite/edgeless/embed.spec.ts
+++ b/tests/affine-local/e2e/blocksuite/edgeless/embed.spec.ts
@@ -1,6 +1,7 @@
import { test } from '@affine-test/kit/playwright';
import {
clickEdgelessModeButton,
+ dblclickNoteBody,
locateEditorContainer,
locateToolbar,
} from '@affine-test/kit/utils/editor';
@@ -43,7 +44,7 @@ test('should close embed editing modal when editor switching to page mode by sho
test('embed card should not overflow the edgeless note', async ({ page }) => {
const note = page.locator('affine-edgeless-note');
- await note.dblclick();
+ await dblclickNoteBody(page);
await type(page, '/github');
await pressEnter(page);
await page
diff --git a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts
index 398335d98e..4d3c7c2815 100644
--- a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts
+++ b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts
@@ -29,6 +29,7 @@ import {
type,
waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic';
+import { clickLocatorByRatio } from '@affine-test/kit/utils/utils';
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import type { IVec } from '@blocksuite/affine/global/gfx';
import type { NoteBlockModel } from '@blocksuite/affine/model';
@@ -160,6 +161,59 @@ test.describe('edgeless page block', () => {
await expect(infoButton).toBeHidden();
});
+ test('caret on focusing', async ({ page }) => {
+ const note = page.locator('affine-edgeless-note');
+ await note.click(); // focus note
+
+ // click on title's rear
+ const docTitle = note.locator('edgeless-page-block-title');
+ await expect(docTitle).toBeVisible();
+ await clickLocatorByRatio(page, docTitle, { xRatio: 0.9, yRatio: 0.8 });
+
+ const hasCaretInTitle = await page.evaluate(
+ hasCaretIn,
+ 'edgeless-page-block-title'
+ );
+ expect(hasCaretInTitle).toBe(true);
+
+ await clickLocatorByRatio(page, note, { xRatio: 1.1, yRatio: 0.1 }); // cancel note focus
+ await note.click(); // focus note again
+
+ // click on firstParagraph's rear
+ const firstParagraph = note.locator('affine-paragraph:first-child');
+ await expect(firstParagraph).toBeVisible();
+
+ await clickLocatorByRatio(page, firstParagraph, {
+ xRatio: 0.9,
+ yRatio: 0.5,
+ });
+
+ const hasCaretInParagraph = await page.evaluate(
+ hasCaretIn,
+ 'affine-paragraph'
+ );
+ expect(hasCaretInParagraph).toBe(true);
+
+ function hasCaretIn(elemSelector: string) {
+ const sel = document.getSelection();
+ if (!sel || sel.rangeCount === 0) return false;
+
+ const startContainer = sel.getRangeAt(0).startContainer;
+ const selContainer =
+ startContainer.nodeType === Node.TEXT_NODE
+ ? startContainer.parentElement
+ : startContainer;
+
+ if (!selContainer) return false;
+
+ const closestDstElem = (selContainer as HTMLElement)?.closest(
+ elemSelector
+ );
+ if (!closestDstElem) return false;
+ return true;
+ }
+ });
+
test('page title should be editable', async ({ page }) => {
const note = page.locator('affine-edgeless-note');
const docTitle = note.locator('edgeless-page-block-title');
diff --git a/tests/affine-local/e2e/blocksuite/embed/synced.spec.ts b/tests/affine-local/e2e/blocksuite/embed/synced.spec.ts
index 3e996ac7e3..21a03a6933 100644
--- a/tests/affine-local/e2e/blocksuite/embed/synced.spec.ts
+++ b/tests/affine-local/e2e/blocksuite/embed/synced.spec.ts
@@ -3,6 +3,7 @@ import {
clickEdgelessModeButton,
clickView,
createEdgelessNoteBlock,
+ dblclickNoteBody,
fitViewportToContent,
focusDocTitle,
getSelectedXYWH,
@@ -35,8 +36,7 @@ test.beforeEach(async ({ page }) => {
test('should not show hidden note in embed view page mode', async ({
page,
}) => {
- const note = page.locator('affine-edgeless-note');
- await note.dblclick();
+ await dblclickNoteBody(page);
await page.keyboard.type('visible content');
await createEdgelessNoteBlock(page, [100, 100]);
await page.keyboard.press('Enter');
diff --git a/tests/kit/src/utils/editor.ts b/tests/kit/src/utils/editor.ts
index 1116f9d0d9..e02f354580 100644
--- a/tests/kit/src/utils/editor.ts
+++ b/tests/kit/src/utils/editor.ts
@@ -6,6 +6,8 @@ import type { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph
import type { BlockComponent } from '@blocksuite/std';
import { expect, type Locator, type Page } from '@playwright/test';
+import { dblclickLocatorByRatio } from './utils';
+
const EDGELESS_TOOLBAR_WIDGET = 'edgeless-toolbar-widget';
export const ZERO_WIDTH_FOR_EMPTY_LINE =
process.env.BROWSER === 'webkit' ? '\u200C' : '\u200B';
@@ -65,6 +67,11 @@ export function locateEditorContainer(page: Page, editorIndex = 0) {
return page.locator('[data-affine-editor-container]').nth(editorIndex);
}
+export async function dblclickNoteBody(page: Page) {
+ const note = page.locator('affine-edgeless-note');
+ await dblclickLocatorByRatio(page, note, { yRatio: 0.7 });
+}
+
export function locateDocTitle(page: Page, editorIndex = 0) {
return locateEditorContainer(page, editorIndex).locator('doc-title');
}
diff --git a/tests/kit/src/utils/utils.ts b/tests/kit/src/utils/utils.ts
index e1433da830..3a079421f0 100644
--- a/tests/kit/src/utils/utils.ts
+++ b/tests/kit/src/utils/utils.ts
@@ -1,6 +1,7 @@
import { setTimeout } from 'node:timers/promises';
import type { Locator, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
import fs from 'fs-extra';
export async function waitForLogMessage(
@@ -70,3 +71,48 @@ export async function isContainedInBoundingBox(
}
return true;
}
+
+/**
+ * Click at a specific position relative to a locator's bounding box.
+ * * Ratios are NOT clamped:
+ * - 0 ~ 1 : inside the bounding box
+ * - < 0 : outside (left / top of the box)
+ * - > 1 : outside (right / bottom of the box)
+ *
+ * @param locator The locator to click
+ * @param options Optional click position ratios
+ * @param options.xRatio Horizontal ratio relative to box width (not clamped), default is 0.5 (center)
+ * @param options.yRatio Vertical ratio relative to box height (not clamped), default is 0.5 (center)
+ */
+export async function clickLocatorByRatio(
+ page: Page,
+ locator: Locator,
+ { xRatio = 0.5, yRatio = 0.5 } = {}
+) {
+ const box = await getLocatorBox(locator);
+
+ await page.mouse.click(
+ box.x + box.width * xRatio,
+ box.y + box.height * yRatio
+ );
+}
+
+export async function dblclickLocatorByRatio(
+ page: Page,
+ locator: Locator,
+ { xRatio = 0.5, yRatio = 0.5 } = {}
+) {
+ const box = await getLocatorBox(locator);
+
+ await page.mouse.dblclick(
+ box.x + box.width * xRatio,
+ box.y + box.height * yRatio
+ );
+}
+
+async function getLocatorBox(locator: Locator) {
+ await expect(locator).toBeVisible();
+ const box = await locator.boundingBox();
+ if (!box) throw new Error(`error getting locator's bounding box`);
+ return box;
+}