diff --git a/packages/blocks/src/_common/components/rich-text/rich-text-operations.ts b/packages/blocks/src/_common/components/rich-text/rich-text-operations.ts index 502eadc53984..f3e9bde3e327 100644 --- a/packages/blocks/src/_common/components/rich-text/rich-text-operations.ts +++ b/packages/blocks/src/_common/components/rich-text/rich-text-operations.ts @@ -648,12 +648,10 @@ function handleParagraphDeleteActions( 'affine:bookmark', 'affine:code', 'affine:image', + 'affine:divider', ...EMBED_BLOCK_FLAVOUR_LIST, ]) ) { - doc.deleteBlock(model, { - bringChildrenTo: parent, - }); const previousSiblingElement = getBlockComponentByModel( editorHost, previousSibling @@ -664,6 +662,12 @@ function handleParagraphDeleteActions( }); editorHost.selection.setGroup('note', [selection]); + if (model.text?.length === 0) { + doc.deleteBlock(model, { + bringChildrenTo: parent, + }); + } + return true; } @@ -835,7 +839,16 @@ function handleParagraphBlockForwardDelete( } function handleEmbedDividerCodeSibling(nextSibling: ExtendedModel | null) { if (matchFlavours(nextSibling, ['affine:divider'])) { - doc.deleteBlock(nextSibling); + const nextSiblingComponent = getBlockComponentByModel( + editorHost, + nextSibling + ); + assertExists(nextSiblingComponent); + editorHost.selection.setGroup('note', [ + editorHost.selection.create('block', { + path: nextSiblingComponent.path, + }), + ]); return true; } diff --git a/packages/blocks/src/note-block/commands/focus-block-end.ts b/packages/blocks/src/note-block/commands/focus-block-end.ts new file mode 100644 index 000000000000..10b84db75466 --- /dev/null +++ b/packages/blocks/src/note-block/commands/focus-block-end.ts @@ -0,0 +1,29 @@ +import type { Command } from '@blocksuite/block-std'; + +export const focusBlockEnd: Command<'focusBlock'> = (ctx, next) => { + const { focusBlock, std } = ctx; + if (!focusBlock || !focusBlock.model.text) return; + + const { selection } = std; + + selection.setGroup('note', [ + selection.create('text', { + from: { + path: focusBlock.path, + index: focusBlock.model.text.length, + length: 0, + }, + to: null, + }), + ]); + + return next(); +}; + +declare global { + namespace BlockSuite { + interface Commands { + focusBlockEnd: typeof focusBlockEnd; + } + } +} diff --git a/packages/blocks/src/note-block/commands/focus-block-start.ts b/packages/blocks/src/note-block/commands/focus-block-start.ts new file mode 100644 index 000000000000..56b3b2cd904d --- /dev/null +++ b/packages/blocks/src/note-block/commands/focus-block-start.ts @@ -0,0 +1,25 @@ +import type { Command } from '@blocksuite/block-std'; + +export const focusBlockStart: Command<'focusBlock'> = (ctx, next) => { + const { focusBlock, std } = ctx; + if (!focusBlock || !focusBlock.model.text) return; + + const { selection } = std; + + selection.setGroup('note', [ + selection.create('text', { + from: { path: focusBlock.path, index: 0, length: 0 }, + to: null, + }), + ]); + + return next(); +}; + +declare global { + namespace BlockSuite { + interface Commands { + focusBlockStart: typeof focusBlockStart; + } + } +} diff --git a/packages/blocks/src/note-block/keymap-controller.ts b/packages/blocks/src/note-block/keymap-controller.ts index 8008fe47ba6d..fc48c4d0c181 100644 --- a/packages/blocks/src/note-block/keymap-controller.ts +++ b/packages/blocks/src/note-block/keymap-controller.ts @@ -12,7 +12,7 @@ import type { ReactiveControllerHost } from 'lit'; import { moveBlockConfigs } from '../_common/configs/move-block.js'; import { quickActionConfig } from '../_common/configs/quick-action/config.js'; import { textConversionConfigs } from '../_common/configs/text-conversion.js'; -import { buildPath } from '../_common/utils/index.js'; +import { buildPath, matchFlavours } from '../_common/utils/index.js'; import { onModelElementUpdated } from '../root-block/utils/callback.js'; import { ensureBlockInContainer } from './utils.js'; @@ -67,7 +67,9 @@ export class KeymapController implements ReactiveController { this._bindMoveBlockHotKey(); }; - private _onArrowDown = () => { + private _onArrowDown = (ctx: UIEventStateContext) => { + const event = ctx.get('defaultState').event; + const [result] = this._std.command .chain() .inline((_, next) => { @@ -75,43 +77,87 @@ export class KeymapController implements ReactiveController { return next(); }) .try(cmd => [ + // text selection - select the next block + // 1. is paragraph, list, code block - follow the default behavior + // 2. is not - select the next block (use block selection instead of text selection) + cmd + .getTextSelection() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentTextSelection = ctx.currentTextSelection; + assertExists(currentTextSelection); + return next({ currentSelectionPath: currentTextSelection.path }); + }) + .getNextBlock({ + filter: block => ensureBlockInContainer(block, this.host), + }) + .inline<'focusBlock'>((ctx, next) => { + const { nextBlock } = ctx; + assertExists(nextBlock); + + if ( + matchFlavours(nextBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) + return; + + return next({ + focusBlock: nextBlock, + }); + }) + .selectBlock(), // block selection - select the next block - this._onBlockDown(cmd), + // 1. is paragraph, list, code block - focus it + // 2. is not - select it using block selection + cmd + .getBlockSelections() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + assertExists(currentBlockSelections); + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) { + return; + } + return next({ currentSelectionPath: blockSelection.path }); + }) + .getNextBlock({ + filter: block => ensureBlockInContainer(block, this.host), + }) + .inline<'focusBlock'>((ctx, next) => { + const { nextBlock } = ctx; + assertExists(nextBlock); + + if ( + matchFlavours(nextBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) { + this._std.command + .chain() + .focusBlockStart({ focusBlock: nextBlock }) + .run(); + event.preventDefault(); + return; + } + + return next({ + focusBlock: nextBlock, + }); + }) + .selectBlock(), ]) .run(); return result; }; - private _onBlockDown = (cmd: BlockSuite.CommandChain) => { - return cmd - .getBlockSelections() - .inline<'currentSelectionPath'>((ctx, next) => { - const currentBlockSelections = ctx.currentBlockSelections; - assertExists(currentBlockSelections); - const blockSelection = currentBlockSelections.at(-1); - if (!blockSelection) { - return; - } - return next({ currentSelectionPath: blockSelection.path }); - }) - .getNextBlock() - .inline<'focusBlock'>((ctx, next) => { - const { nextBlock } = ctx; - assertExists(nextBlock); - - if (!ensureBlockInContainer(nextBlock, this.host)) { - return; - } - - return next({ - focusBlock: nextBlock, - }); - }) - .selectBlock(); - }; + private _onArrowUp = (ctx: UIEventStateContext) => { + const event = ctx.get('defaultState').event; - private _onArrowUp = () => { const [result] = this._std.command .chain() .inline((_, next) => { @@ -119,42 +165,84 @@ export class KeymapController implements ReactiveController { return next(); }) .try(cmd => [ + // text selection - select the previous block + // 1. is paragraph, list, code block - follow the default behavior + // 2. is not - select the previous block (use block selection instead of text selection) + cmd + .getTextSelection() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentTextSelection = ctx.currentTextSelection; + assertExists(currentTextSelection); + return next({ currentSelectionPath: currentTextSelection.path }); + }) + .getPrevBlock({ + filter: block => ensureBlockInContainer(block, this.host), + }) + .inline<'focusBlock'>((ctx, next) => { + const { prevBlock } = ctx; + assertExists(prevBlock); + + if ( + matchFlavours(prevBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) + return; + + return next({ + focusBlock: prevBlock, + }); + }) + .selectBlock(), // block selection - select the previous block - this._onBlockUp(cmd), + // 1. is paragraph, list, code block - focus it + // 2. is not - select it using block selection + cmd + .getBlockSelections() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + assertExists(currentBlockSelections); + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) { + return; + } + return next({ currentSelectionPath: blockSelection.path }); + }) + .getPrevBlock({ + filter: block => ensureBlockInContainer(block, this.host), + }) + .inline<'focusBlock'>((ctx, next) => { + const { prevBlock } = ctx; + assertExists(prevBlock); + + if ( + matchFlavours(prevBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) { + this._std.command + .chain() + .focusBlockEnd({ focusBlock: prevBlock }) + .run(); + event.preventDefault(); + return; + } + + return next({ + focusBlock: prevBlock, + }); + }) + .selectBlock(), ]) .run(); return result; }; - private _onBlockUp = (cmd: BlockSuite.CommandChain) => { - return cmd - .getBlockSelections() - .inline<'currentSelectionPath'>((ctx, next) => { - const currentBlockSelections = ctx.currentBlockSelections; - assertExists(currentBlockSelections); - const blockSelection = currentBlockSelections.at(0); - if (!blockSelection) { - return; - } - return next({ currentSelectionPath: blockSelection.path }); - }) - .getPrevBlock() - .inline((ctx, next) => { - const { prevBlock } = ctx; - assertExists(prevBlock); - - if (!ensureBlockInContainer(prevBlock, this.host)) { - return; - } - - return next({ - focusBlock: prevBlock, - }); - }) - .selectBlock(); - }; - private _onShiftArrowDown = () => { const [result] = this._std.command .chain() diff --git a/packages/blocks/src/note-block/note-service.ts b/packages/blocks/src/note-block/note-service.ts index 2380d454c8fd..09567a505555 100644 --- a/packages/blocks/src/note-block/note-service.ts +++ b/packages/blocks/src/note-block/note-service.ts @@ -12,6 +12,8 @@ import { captureEventTarget, getDuplicateBlocks, } from '../root-block/widgets/drag-handle/utils.js'; +import { focusBlockEnd } from './commands/focus-block-end.js'; +import { focusBlockStart } from './commands/focus-block-start.js'; import { registerTextStyleCommands, selectBlock, @@ -131,6 +133,8 @@ export class NoteService extends BlockService { this.std.command .add('selectBlocksBetween', selectBlocksBetween) .add('selectBlock', selectBlock) + .add('focusBlockStart', focusBlockStart) + .add('focusBlockEnd', focusBlockEnd) .add('updateBlockType', updateBlockType); registerTextStyleCommands(this.std); diff --git a/tests/paragraph.spec.ts b/tests/paragraph.spec.ts index ef15ade2f17c..e7d4f0b1cf18 100644 --- a/tests/paragraph.spec.ts +++ b/tests/paragraph.spec.ts @@ -37,6 +37,7 @@ import { assertBlockChildrenFlavours, assertBlockChildrenIds, assertBlockCount, + assertBlockSelections, assertBlockType, assertClassName, assertDivider, @@ -1864,7 +1865,7 @@ test('arrow up/down navigation within and across paragraphs containing different await assertRichTextInlineRange(page, 1, 125, 0); }); -test('delete divider using keyboard from prev/next paragraph', async ({ +test('select divider using delete keyboard from prev/next paragraph', async ({ page, }) => { test.info().annotations.push({ @@ -1881,12 +1882,14 @@ test('delete divider using keyboard from prev/next paragraph', async ({ await focusRichText(page, 0); await pressForwardDelete(page); - await assertDivider(page, 2); + await assertBlockSelections(page, [['0', '1', '4']]); + await assertDivider(page, 3); await focusRichText(page, 1); await pressArrowLeft(page, 3); await pressBackspace(page); - await assertDivider(page, 1); + await assertBlockSelections(page, [['0', '1', '6']]); + await assertDivider(page, 3); await assertRichTexts(page, ['123', '123']); }); diff --git a/tests/selection/native.spec.ts b/tests/selection/native.spec.ts index 150226150f98..8f2192ccc8f3 100644 --- a/tests/selection/native.spec.ts +++ b/tests/selection/native.spec.ts @@ -52,9 +52,11 @@ import { } from '../utils/actions/index.js'; import { assertBlockCount, + assertBlockSelections, assertClipItems, assertDivider, assertExists, + assertNativeSelectionRangeCount, assertRichTextInlineRange, assertRichTexts, assertStoreMatchJSX, @@ -939,11 +941,18 @@ test('Delete the blank line between two dividers', async ({ page }) => { await waitNextFrame(page); await pressEnter(page); await type(page, '--- '); - await pressArrowUp(page, 2); - await pressBackspace(page); - await waitNextFrame(page); await assertDivider(page, 2); await assertRichTexts(page, ['', '']); + + await pressArrowUp(page); + await assertBlockSelections(page, [['0', '1', '5']]); + await pressArrowUp(page); + await assertBlockSelections(page, []); + await assertRichTextInlineRange(page, 0, 0); + await pressBackspace(page); + await assertRichTexts(page, ['']); + await assertBlockSelections(page, [['0', '1', '3']]); + await assertDivider(page, 2); }); test('Delete the second divider between two dividers by forwardDelete', async ({ @@ -977,9 +986,9 @@ test('should delete line with content after divider not lose content', async ({ // Jump to line start await page.keyboard.press(`${SHORT_KEY}+ArrowLeft`, { delay: 50 }); await waitNextFrame(page); - await pressBackspace(page); + await pressBackspace(page, 2); await assertDivider(page, 0); - await assertRichTexts(page, ['123']); + await assertRichTexts(page, ['', '123']); }); test('should forwardDelete divider works properly', async ({ page }) => { @@ -2040,3 +2049,60 @@ test('auto-scroll when creating a new paragraph-block by pressing enter', async }); expect(scrollTop).toBeGreaterThan(1000); }); + +test('Use arrow up and down to select two types of block', async ({ page }) => { + await enterPlaygroundRoom(page); + await initEmptyParagraphState(page); + await focusRichText(page); + await type(page, '123'); + await pressEnter(page); + await type(page, '--- --- '); + await type(page, '123'); + await pressEnter(page); + await type(page, '--- 123'); + // 123 + // --- + // --- + // 123 + // --- + // 123 + + await assertDivider(page, 3); + await assertRichTexts(page, ['123', '123', '123']); + + // from bottom to top + await assertNativeSelectionRangeCount(page, 1); + await assertRichTextInlineRange(page, 2, 3); + await pressArrowUp(page); + await assertNativeSelectionRangeCount(page, 0); + await assertBlockSelections(page, [['0', '1', '7']]); + await pressArrowUp(page); + await assertNativeSelectionRangeCount(page, 1); + await assertRichTextInlineRange(page, 1, 3); + await pressArrowUp(page); + await assertNativeSelectionRangeCount(page, 0); + await assertBlockSelections(page, [['0', '1', '5']]); + await pressArrowUp(page); + await assertNativeSelectionRangeCount(page, 0); + await assertBlockSelections(page, [['0', '1', '4']]); + await pressArrowUp(page); + await assertNativeSelectionRangeCount(page, 1); + await assertRichTextInlineRange(page, 0, 3); + + // from top to bottom + await pressArrowDown(page); + await assertNativeSelectionRangeCount(page, 0); + await assertBlockSelections(page, [['0', '1', '4']]); + await pressArrowDown(page); + await assertNativeSelectionRangeCount(page, 0); + await assertBlockSelections(page, [['0', '1', '5']]); + await pressArrowDown(page); + await assertNativeSelectionRangeCount(page, 1); + await assertRichTextInlineRange(page, 1, 0); + await pressArrowDown(page); + await assertNativeSelectionRangeCount(page, 0); + await assertBlockSelections(page, [['0', '1', '7']]); + await pressArrowDown(page); + await assertNativeSelectionRangeCount(page, 1); + await assertRichTextInlineRange(page, 2, 0); +});