fix(editor): behavior of deleting at the start of line (#12787)

Close BS-3182, #12736 




#### PR Dependency Tree


* **PR #12787** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Improved the behavior when deleting empty lines and merging blocks,
ensuring more accurate handling of block deletion and cursor focus in
various scenarios.
- **Tests**
- Added new end-to-end tests to verify correct deletion of lines in
edgeless text and paragraph blocks, including checks for block removal
and cursor position.
- Introduced a utility function to retrieve block IDs for testing
purposes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-06-12 14:58:03 +08:00
committed by GitHub
parent d12954f8c3
commit 8d2214424c
4 changed files with 138 additions and 16 deletions

View File

@@ -20,6 +20,7 @@ import { EMBED_BLOCK_MODEL_LIST } from '@blocksuite/affine-shared/consts';
import type { ExtendedModel } from '@blocksuite/affine-shared/types'; import type { ExtendedModel } from '@blocksuite/affine-shared/types';
import { import {
focusTitle, focusTitle,
getDocTitleInlineEditor,
getPrevContentBlock, getPrevContentBlock,
matchModels, matchModels,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
@@ -45,10 +46,6 @@ export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) {
const parent = doc.getParent(model); const parent = doc.getParent(model);
if (!parent) return false; if (!parent) return false;
if (matchModels(parent, [EdgelessTextBlockModel])) {
return true;
}
const prevBlock = getPrevContentBlock(editorHost, model); const prevBlock = getPrevContentBlock(editorHost, model);
if (!prevBlock) { if (!prevBlock) {
return handleNoPreviousSibling(editorHost, model); return handleNoPreviousSibling(editorHost, model);
@@ -123,36 +120,63 @@ function handleNoPreviousSibling(editorHost: EditorHost, model: ExtendedModel) {
const parent = doc.getParent(model); const parent = doc.getParent(model);
if (!parent) return false; if (!parent) return false;
if (matchModels(parent, [NoteBlockModel]) && parent.isPageBlock()) { const focusFirstBlockStart = () => {
const firstBlock = parent.firstChild();
if (firstBlock) {
focusTextModel(editorHost.std, firstBlock.id, 0);
}
};
if (matchModels(parent, [NoteBlockModel])) {
const hasTitleEditor = getDocTitleInlineEditor(editorHost);
const rootModel = model.store.root as RootBlockModel; const rootModel = model.store.root as RootBlockModel;
const title = rootModel.props.title; const title = rootModel.props.title;
const shouldHandleTitle = parent.isPageBlock() && hasTitleEditor;
doc.captureSync(); doc.captureSync();
let textLength = 0;
if (text) { if (shouldHandleTitle) {
textLength = text.length; let textLength = 0;
title.join(text); if (text) {
textLength = text.length;
title.join(text);
}
if (model.children.length > 0 || doc.getNext(model)) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
}
// no other blocks, preserve a empty line
else {
text?.clear();
}
focusTitle(editorHost, title.length - textLength);
return true;
} }
// Preserve at least one block to be able to focus on container click // Preserve at least one block to be able to focus on container click
if (doc.getNext(model) || model.children.length > 0) { if (
text?.length === 0 &&
(model.children.length > 0 || doc.getNext(model))
) {
doc.deleteBlock(model, { doc.deleteBlock(model, {
bringChildrenTo: parent, bringChildrenTo: parent,
}); });
} else { focusFirstBlockStart();
text?.clear(); return true;
} }
focusTitle(editorHost, title.length - textLength);
return true;
} }
if ( if (
matchModels(parent, [EdgelessTextBlockModel]) || matchModels(parent, [EdgelessTextBlockModel]) &&
model.children.length > 0 text?.length === 0 &&
(model.children.length > 0 || doc.getNext(model))
) { ) {
doc.deleteBlock(model, { doc.deleteBlock(model, {
bringChildrenTo: parent, bringChildrenTo: parent,
}); });
focusFirstBlockStart();
return true; return true;
} }

View File

@@ -11,6 +11,8 @@ import {
edgelessCommonSetup, edgelessCommonSetup,
enterPlaygroundRoom, enterPlaygroundRoom,
getEdgelessSelectedRect, getEdgelessSelectedRect,
getIds,
getInlineSelectionIndex,
getPageSnapshot, getPageSnapshot,
initEmptyEdgelessState, initEmptyEdgelessState,
pasteByKeyboard, pasteByKeyboard,
@@ -21,6 +23,8 @@ import {
pressBackspace, pressBackspace,
pressEnter, pressEnter,
pressEscape, pressEscape,
pressShiftTab,
pressTab,
redoByKeyboard, redoByKeyboard,
selectAllByKeyboard, selectAllByKeyboard,
setEdgelessTool, setEdgelessTool,
@@ -552,6 +556,52 @@ test.describe('edgeless text block', () => {
1 1
); );
}); });
test('edgeless text should be able to delete line', async ({ page }) => {
await dblclickView(page, [100, -100]);
// 5: aaa
// 6: bbb
// 7: ccc|
{
await type(page, 'aaa');
await pressEnter(page);
await pressTab(page);
await type(page, 'bbb');
await pressEnter(page);
await pressShiftTab(page);
await type(page, 'ccc');
}
// 5: aaa
// 6: bbb|
await pressBackspace(page, 4);
expect(await getIds(page)).not.toContain(7);
await assertBlockTextContent(page, 5, 'aaa');
await assertBlockTextContent(page, 6, 'bbb');
expect(await getInlineSelectionIndex(page)).toBe(3);
// 5: |aaa
// 6: bbb
{
await pressArrowUp(page);
await pressArrowLeft(page, 3);
await pressBackspace(page);
}
await assertBlockTextContent(page, 5, 'aaa');
await assertBlockTextContent(page, 6, 'bbb');
expect(await getInlineSelectionIndex(page)).toBe(0);
// 6: |bbb
{
await pressArrowRight(page, 3);
await pressBackspace(page, 4);
}
expect(await getIds(page)).not.toContain(7);
await assertBlockTextContent(page, 6, 'bbb');
expect(await getInlineSelectionIndex(page)).toBe(0);
});
}); });
test('press backspace at the start of first line when edgeless text exist', async ({ test('press backspace at the start of first line when edgeless text exist', async ({

View File

@@ -8,7 +8,9 @@ import {
enterPlaygroundRoom, enterPlaygroundRoom,
focusRichText, focusRichText,
focusTitle, focusTitle,
getBlockIds,
getIndexCoordinate, getIndexCoordinate,
getInlineSelectionIndex,
getPageSnapshot, getPageSnapshot,
initEmptyEdgelessState, initEmptyEdgelessState,
initEmptyParagraphState, initEmptyParagraphState,
@@ -44,6 +46,7 @@ import {
assertBlockChildrenIds, assertBlockChildrenIds,
assertBlockCount, assertBlockCount,
assertBlockSelections, assertBlockSelections,
assertBlockTextContent,
assertBlockType, assertBlockType,
assertClassName, assertClassName,
assertDivider, assertDivider,
@@ -734,6 +737,45 @@ test('delete at start of paragraph with content', async ({ page }) => {
await assertRichTexts(page, ['123', '456']); await assertRichTexts(page, ['123', '456']);
}); });
test('delete empty line should work correctly', async ({ page }) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
await focusRichText(page);
// 2: aaa
// 3: bbb
// 4: ccc|
{
await type(page, 'aaa');
await pressEnter(page);
await pressTab(page);
await type(page, 'bbb');
await pressEnter(page);
await pressShiftTab(page);
await type(page, 'ccc');
}
// 2: aaa
// 3: bbb|
await pressBackspace(page, 4);
expect(await getBlockIds(page)).not.toContain(4);
await assertBlockTextContent(page, 2, 'aaa');
await assertBlockTextContent(page, 3, 'bbb');
expect(await getInlineSelectionIndex(page)).toBe(3);
// title: |aaa
// 3: bbb
{
await pressArrowUp(page);
await pressArrowLeft(page, 3);
await pressBackspace(page);
}
await expect(page.locator('doc-title')).toContainText('aaa');
expect(await getBlockIds(page)).not.toContain(2);
await assertBlockTextContent(page, 3, 'bbb');
expect(await getInlineSelectionIndex(page)).toBe(0);
});
test('get focus from page title enter', async ({ page }) => { test('get focus from page title enter', async ({ page }) => {
await enterPlaygroundRoom(page); await enterPlaygroundRoom(page);
await initEmptyParagraphState(page); await initEmptyParagraphState(page);

View File

@@ -23,3 +23,9 @@ export async function updateBlockType(
); );
await waitNextFrame(page, 400); await waitNextFrame(page, 400);
} }
export async function getBlockIds(page: Page) {
return page.evaluate(() => {
return window.host.std.store.getAllModels().map(m => m.id);
});
}