From 665ce8bc0efb4024b127191284517a6478645bd0 Mon Sep 17 00:00:00 2001 From: Nowely Date: Tue, 10 Mar 2026 11:09:09 +0300 Subject: [PATCH 1/3] feat(blocks): enhance block merging functionality with Delete key - Implemented merging of blocks when Delete is pressed at the start of a non-first block, similar to Backspace behavior. - Updated tests to cover new merging scenarios, including preserving content and maintaining focus after merges. - Added checks to prevent merging when Delete is pressed at the end of the last block or at the start of the first block. This improves the user experience by allowing more intuitive block management in the editor. --- .../src/features/input/KeyDownController.ts | 22 +++- .../storybook/src/pages/Block/Block.spec.ts | 112 +++++++++++++----- 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts index d3f898b3..6ba37c87 100644 --- a/packages/common/core/src/features/input/KeyDownController.ts +++ b/packages/common/core/src/features/input/KeyDownController.ts @@ -159,7 +159,27 @@ export class KeyDownController { if (event.key === KEYBOARD.DELETE) { const blockDiv = blockDivs[blockIndex] as HTMLElement - const caretAtEnd = Caret.getCaretIndex(blockDiv) === blockDiv.textContent?.length + const caretIndex = Caret.getCaretIndex(blockDiv) + const caretAtEnd = caretIndex === blockDiv.textContent?.length + const caretAtStart = caretIndex === 0 + + // Caret at start of non-first block: merge current block into previous (like Backspace at start) + if (caretAtStart && blockIndex > 0) { + event.preventDefault() + const joinPos = blocks[blockIndex - 1].endPos + const newValue = mergeBlocks(value, blocks, blockIndex) + this.store.applyValue(newValue) + queueMicrotask(() => { + const newDivs = container.children + const target = newDivs[blockIndex - 1] as HTMLElement | undefined + if (target) { + target.focus() + const charOffset = joinPos - blocks[blockIndex - 1].startPos + Caret.trySetIndex(target, charOffset) + } + }) + return + } // Caret at end of non-last block: merge next block into current if (caretAtEnd && blockIndex < blocks.length - 1) { diff --git a/packages/vue/storybook/src/pages/Block/Block.spec.ts b/packages/vue/storybook/src/pages/Block/Block.spec.ts index fd48c7f2..b5936707 100644 --- a/packages/vue/storybook/src/pages/Block/Block.spec.ts +++ b/packages/vue/storybook/src/pages/Block/Block.spec.ts @@ -590,46 +590,102 @@ describe('Feature: block keyboard navigation', () => { }) describe('Delete merge blocks', () => { - it('should merge with next block when Delete pressed at end of non-last block', async () => { - const {container} = await render(PlainTextBlocks) - const before = getBlocks(container).length + describe('Delete at end of block', () => { + it('should merge with next block when Delete pressed at end of non-last block', async () => { + const {container} = await render(PlainTextBlocks) + const before = getBlocks(container).length - await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) - await userEvent.keyboard('{Delete}') + await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) + await userEvent.keyboard('{Delete}') - expect(getBlocks(container)).toHaveLength(before - 1) - }) + expect(getBlocks(container)).toHaveLength(before - 1) + }) - it('should preserve content of both merged blocks', async () => { - const {container} = await render(PlainTextBlocks) + it('should preserve content of both merged blocks', async () => { + const {container} = await render(PlainTextBlocks) - await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) - await userEvent.keyboard('{Delete}') + await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) + await userEvent.keyboard('{Delete}') - const raw = getRawValue(container) - expect(raw).toContain('First block of plain text') - expect(raw).toContain('Second block of plain text') - }) + const raw = getRawValue(container) + expect(raw).toContain('First block of plain text') + expect(raw).toContain('Second block of plain text') + }) - it('should keep focus in the current block after Delete merge', async () => { - const {container} = await render(PlainTextBlocks) - const currentBlock = getBlocks(container)[0] + it('should keep focus in the current block after Delete merge', async () => { + const {container} = await render(PlainTextBlocks) + const currentBlock = getBlocks(container)[0] - await focusAtEnd(getEditableInBlock(currentBlock)) - await userEvent.keyboard('{Delete}') + await focusAtEnd(getEditableInBlock(currentBlock)) + await userEvent.keyboard('{Delete}') - expect(document.activeElement).toBe(currentBlock) + expect(document.activeElement).toBe(currentBlock) + }) + + it('should not merge when Delete pressed at end of last block', async () => { + const {container} = await render(PlainTextBlocks) + const blocks = getBlocks(container) + const last = blocks[blocks.length - 1] + + await focusAtEnd(getEditableInBlock(last)) + await userEvent.keyboard('{Delete}') + + expect(getBlocks(container)).toHaveLength(5) + }) }) - it('should not merge when Delete pressed at end of last block', async () => { - const {container} = await render(PlainTextBlocks) - const blocks = getBlocks(container) - const last = blocks[blocks.length - 1] + describe('Delete at start of block', () => { + it('should merge current block into previous when Delete pressed at start of non-first block', async () => { + const {container} = await render(PlainTextBlocks) + const before = getBlocks(container).length - await focusAtEnd(getEditableInBlock(last)) - await userEvent.keyboard('{Delete}') + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') - expect(getBlocks(container)).toHaveLength(5) + expect(getBlocks(container)).toHaveLength(before - 1) + }) + + it('should preserve content of both merged blocks', async () => { + const {container} = await render(PlainTextBlocks) + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + const raw = getRawValue(container) + expect(raw).toContain('First block of plain text') + expect(raw).toContain('Second block of plain text') + }) + + it('should move focus to the previous block after merge', async () => { + const {container} = await render(PlainTextBlocks) + const prevBlock = getBlocks(container)[0] + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + expect(document.activeElement).toBe(prevBlock) + }) + + it('should not merge when Delete pressed at start of the first block', async () => { + const {container} = await render(PlainTextBlocks) + + await focusAtStart(getEditableInBlock(getBlocks(container)[0])) + await userEvent.keyboard('{Delete}') + + expect(getBlocks(container)).toHaveLength(5) + }) + + it('should place caret at the join point after merge', async () => { + const {container} = await render(PlainTextBlocks) + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + // After merge, typing should insert right at the join point + // (between the end of block 0 and start of block 1 text) + const raw = getRawValue(container) + expect(raw).toContain('First block of plain textSecond block of plain text') + }) }) }) From 8c6ed5651f570e8056d3b616d2295dd79c310742 Mon Sep 17 00:00:00 2001 From: Nowely Date: Tue, 10 Mar 2026 21:33:01 +0300 Subject: [PATCH 2/3] feat(blocks): add mergeBlocks and getMergeJoinPos functions for enhanced block merging - Introduced `mergeBlocks` to merge adjacent blocks by removing separators, handling cases where separators are embedded in mark tokens. - Added `getMergeJoinPos` to determine the correct join position for merging blocks, improving caret positioning. - Expanded unit tests to cover new merging scenarios, including edge cases with embedded separators. - Updated `KeyDownController` to utilize the new functions for better block management during keyboard interactions. This enhances the block editing experience by ensuring seamless merging and caret management in the editor. --- .../features/blocks/blockOperations.spec.ts | 55 +++++++- .../src/features/blocks/blockOperations.ts | 34 ++++- .../src/features/input/KeyDownController.ts | 104 ++++++++++++++-- .../storybook/src/pages/Block/Block.spec.tsx | 117 ++++++++++++++++++ .../storybook/src/pages/Block/Block.spec.ts | 74 +++++++++++ 5 files changed, 370 insertions(+), 14 deletions(-) diff --git a/packages/common/core/src/features/blocks/blockOperations.spec.ts b/packages/common/core/src/features/blocks/blockOperations.spec.ts index e0399733..476cf947 100644 --- a/packages/common/core/src/features/blocks/blockOperations.spec.ts +++ b/packages/common/core/src/features/blocks/blockOperations.spec.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from 'vitest' -import {addBlock, deleteBlock, duplicateBlock} from './blockOperations' +import {addBlock, deleteBlock, duplicateBlock, getMergeJoinPos, mergeBlocks} from './blockOperations' import type {Block} from './splitTokensIntoBlocks' function makeBlock(id: string, startPos: number, endPos: number): Block { @@ -53,6 +53,59 @@ describe('deleteBlock', () => { }) }) +describe('mergeBlocks', () => { + it('removes the separator between two blocks (standard case)', () => { + // value: "A\n\nB\n\nC", blocks: [0,1], [3,4], [6,7] + expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 1)).toBe('AB\n\nC') + }) + + it('merges last block into previous', () => { + expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nBC') + }) + + it('returns value unchanged when index is 0', () => { + expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('A\n\nB\n\nC') + }) + + describe('embedded separator (mark-ending-with-\\n\\n)', () => { + // When a mark token consumes the \n\n as part of its content, splitTokensIntoBlocks + // sets endPos of that block to AFTER the \n\n, making endPos === next block's startPos. + // Example: "# Heading\n\nBody" where the heading mark includes the trailing \n\n. + // block0: startPos=0, endPos=11 ("# Heading\n\n" length = 11) + // block1: startPos=11, endPos=15 ("Body") + + const HEADING_BLOCKS: Block[] = [makeBlock('0', 0, 11), makeBlock('11', 11, 15)] + + it('removes the embedded separator from the end of the previous block', () => { + // "# Heading\n\nBody" → "# HeadingBody" + expect(mergeBlocks('# Heading\n\nBody', HEADING_BLOCKS, 1)).toBe('# HeadingBody') + }) + + it('does not return the original value unchanged', () => { + const result = mergeBlocks('# Heading\n\nBody', HEADING_BLOCKS, 1) + expect(result).not.toBe('# Heading\n\nBody') + }) + }) +}) + +describe('getMergeJoinPos', () => { + it('returns endPos of previous block in the standard case', () => { + // THREE_BLOCKS: [0,1], [3,4], [6,7] — endPos=1, next startPos=3 (different) + expect(getMergeJoinPos(THREE_BLOCKS, 1)).toBe(1) + expect(getMergeJoinPos(THREE_BLOCKS, 2)).toBe(4) + }) + + it('returns endPos minus separator length when separator is embedded in previous mark', () => { + // block0 endPos=11, block1 startPos=11 → separator is embedded, join is at 11-2=9 + const HEADING_BLOCKS: Block[] = [makeBlock('0', 0, 11), makeBlock('11', 11, 15)] + expect(getMergeJoinPos(HEADING_BLOCKS, 1)).toBe(9) + }) + + it('returns 0 for index=0', () => { + expect(getMergeJoinPos(THREE_BLOCKS, 0)).toBe(0) + }) +}) + describe('duplicateBlock', () => { it('duplicates last block by appending', () => { expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB\n\nC\n\nC') diff --git a/packages/common/core/src/features/blocks/blockOperations.ts b/packages/common/core/src/features/blocks/blockOperations.ts index 70ff99cf..0ad87c23 100644 --- a/packages/common/core/src/features/blocks/blockOperations.ts +++ b/packages/common/core/src/features/blocks/blockOperations.ts @@ -20,15 +20,43 @@ export function deleteBlock(value: string, blocks: Block[], index: number): stri return value.slice(0, blocks[index].startPos) + value.slice(blocks[index + 1].startPos) } +/** + * Returns the raw-value position of the join point between block[index-1] and block[index]. + * + * Normally this is `blocks[index-1].endPos` — the position right after the previous block's + * content, before the `\n\n` separator. + * + * Exception: when a mark token (e.g. a heading `# …\n\n`) consumes the `\n\n` as part of its + * content, `splitTokensIntoBlocks` sets `endPos = token.position.end` (which is AFTER the `\n\n`) + * and the next block's `startPos` equals that same value. In that case the separator is embedded + * inside the previous block's range, and the true join point is `endPos - BLOCK_SEPARATOR.length`. + */ +export function getMergeJoinPos(blocks: Block[], index: number): number { + if (index <= 0 || index >= blocks.length) return 0 + const prev = blocks[index - 1] + const curr = blocks[index] + if (prev.endPos === curr.startPos) { + return prev.endPos - BLOCK_SEPARATOR.length + } + return prev.endPos +} + /** * Merges block at `index` into block at `index - 1` by removing the separator between them. * Returns the new value string with the separator removed. - * The caret join point in the raw value is `blocks[index - 1].endPos`. + * Use `getMergeJoinPos` to obtain the raw caret position after the merge. */ export function mergeBlocks(value: string, blocks: Block[], index: number): string { if (index <= 0 || index >= blocks.length) return value - // Remove everything between endPos of previous block and startPos of current block (the separator) - return value.slice(0, blocks[index - 1].endPos) + value.slice(blocks[index].startPos) + const prev = blocks[index - 1] + const curr = blocks[index] + if (prev.endPos === curr.startPos) { + // The \n\n separator is embedded inside the previous block's mark token. + // Remove it from the end of prev's content. + return value.slice(0, prev.endPos - BLOCK_SEPARATOR.length) + value.slice(curr.startPos) + } + // Remove everything between endPos of previous block and startPos of current block + return value.slice(0, prev.endPos) + value.slice(curr.startPos) } export function duplicateBlock(value: string, blocks: Block[], index: number): string { diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts index 6ba37c87..0c971e7a 100644 --- a/packages/common/core/src/features/input/KeyDownController.ts +++ b/packages/common/core/src/features/input/KeyDownController.ts @@ -1,6 +1,6 @@ import type {NodeProxy} from '../../shared/classes/NodeProxy' import {KEYBOARD} from '../../shared/constants' -import {deleteBlock, mergeBlocks} from '../blocks/blockOperations' +import {deleteBlock, getMergeJoinPos, mergeBlocks} from '../blocks/blockOperations' import {BLOCK_SEPARATOR} from '../blocks/config' import {splitTokensIntoBlocks, type Block} from '../blocks/splitTokensIntoBlocks' import {Caret} from '../caret' @@ -141,7 +141,7 @@ export class KeyDownController { // Non-empty block at position 0: merge with previous block if (caretAtStart && blockIndex > 0) { event.preventDefault() - const joinPos = blocks[blockIndex - 1].endPos + const joinPos = getMergeJoinPos(blocks, blockIndex) const newValue = mergeBlocks(value, blocks, blockIndex) this.store.applyValue(newValue) queueMicrotask(() => { @@ -149,8 +149,9 @@ export class KeyDownController { const target = newDivs[blockIndex - 1] as HTMLElement | undefined if (target) { target.focus() - const charOffset = joinPos - blocks[blockIndex - 1].startPos - Caret.trySetIndex(target, charOffset) + const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex - 1] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) } }) return @@ -166,7 +167,7 @@ export class KeyDownController { // Caret at start of non-first block: merge current block into previous (like Backspace at start) if (caretAtStart && blockIndex > 0) { event.preventDefault() - const joinPos = blocks[blockIndex - 1].endPos + const joinPos = getMergeJoinPos(blocks, blockIndex) const newValue = mergeBlocks(value, blocks, blockIndex) this.store.applyValue(newValue) queueMicrotask(() => { @@ -174,8 +175,9 @@ export class KeyDownController { const target = newDivs[blockIndex - 1] as HTMLElement | undefined if (target) { target.focus() - const charOffset = joinPos - blocks[blockIndex - 1].startPos - Caret.trySetIndex(target, charOffset) + const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex - 1] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) } }) return @@ -192,8 +194,9 @@ export class KeyDownController { const target = newDivs[blockIndex] as HTMLElement | undefined if (target) { target.focus() - const charOffset = joinPos - block.startPos - Caret.trySetIndex(target, charOffset) + const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) } }) return @@ -547,11 +550,84 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void { } } +/** + * Recursively sets the caret inside a mark token's rendered DOM element. + * + * For simple marks (no children), the mark renders its nested content as a single + * text node; we position the caret at `rawAbsolutePos - nested.start` within it. + * + * For complex marks with nested tokens (e.g. a heading that spans multiple inline + * marks), we walk the mark element's childNodes in parallel with token children, + * preferring later tokens at boundaries so a position equal to a mark's end + * resolves into the following sibling text rather than the mark itself. + * + * Returns true when the caret was successfully placed, false when the position + * could not be resolved (caller should fall back to end-of-block). + */ +function setCaretInMarkAtRawPos(markElement: HTMLElement, markToken: MarkToken, rawAbsolutePos: number): boolean { + const sel = window.getSelection() + if (!sel) return false + + if (!markToken.children || markToken.children.length === 0) { + // Simple mark: renders nested content as a single text node. + const nestedStart = markToken.nested?.start ?? markToken.position.start + const nestedEnd = markToken.nested?.end ?? markToken.position.end + const offsetInNested = Math.max(0, Math.min(rawAbsolutePos - nestedStart, nestedEnd - nestedStart)) + const walker = document.createTreeWalker(markElement, 4 /* SHOW_TEXT */) + const textNode = walker.nextNode() as Text | null + if (!textNode) return false + const range = document.createRange() + range.setStart(textNode, Math.min(offsetInNested, textNode.length)) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + return true + } + + // Complex mark: walk childNodes in parallel with token children. + // Comment / fragment nodes are skipped without advancing the token index. + let tokenIdx = 0 + for (const childNode of Array.from(markElement.childNodes)) { + if (tokenIdx >= markToken.children.length) break + const tokenChild = markToken.children[tokenIdx] + + if (childNode.nodeType === Node.TEXT_NODE && tokenChild.type === 'text') { + if (rawAbsolutePos >= tokenChild.position.start && rawAbsolutePos <= tokenChild.position.end) { + const offset = Math.min(rawAbsolutePos - tokenChild.position.start, (childNode as Text).length) + const range = document.createRange() + range.setStart(childNode, offset) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + return true + } + tokenIdx++ + } else if (childNode.nodeType === Node.ELEMENT_NODE && tokenChild.type === 'mark') { + // At a boundary, prefer the next sibling text over the current mark end. + const nextChild = tokenIdx + 1 < markToken.children.length ? markToken.children[tokenIdx + 1] : null + const atBoundary = + rawAbsolutePos === tokenChild.position.end && nextChild?.position.start === rawAbsolutePos + if ( + !atBoundary && + rawAbsolutePos >= tokenChild.position.start && + rawAbsolutePos <= tokenChild.position.end + ) { + return setCaretInMarkAtRawPos(childNode as HTMLElement, tokenChild as MarkToken, rawAbsolutePos) + } + tokenIdx++ + } + // Other node types (comments, etc.) — skip without advancing tokenIdx. + } + + return false +} + /** * Sets the caret at an absolute raw-value position within a block's DOM element. * Finds the token that contains `rawAbsolutePos`, then directly positions the * caret in that token's DOM text node — bypassing visual character counting * which is incorrect when mark tokens are present (raw length ≠ visual length). + * For mark tokens, recursively searches nested content via setCaretInMarkAtRawPos. */ function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: number): void { const sel = window.getSelection() @@ -565,6 +641,13 @@ function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: n const domChild = blockChildren[i + 1] as HTMLElement | undefined if (!domChild) continue + // At a boundary between tokens, prefer the later (next) token so that + // a position equal to the end of a mark resolves into the following text. + const nextToken = block.tokens[i + 1] + if (nextToken && rawAbsolutePos === token.position.end && rawAbsolutePos === nextToken.position.start) { + continue + } + if (rawAbsolutePos >= token.position.start && rawAbsolutePos <= token.position.end) { if (token.type === 'text') { const offsetWithinToken = rawAbsolutePos - token.position.start @@ -580,7 +663,8 @@ function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: n return } } - // Mark token: fall through to end-of-block fallback + // Mark token: recurse into its nested DOM content + if (setCaretInMarkAtRawPos(domChild, token as MarkToken, rawAbsolutePos)) return break } } diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx index 0997810d..1f1f6277 100644 --- a/packages/react/storybook/src/pages/Block/Block.spec.tsx +++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx @@ -615,6 +615,43 @@ describe('Feature: block keyboard navigation', () => { // Must be exactly 4 — not 3 (double-delete regression guard) expect(getBlocks(container)).toHaveLength(4) }) + + describe('Backspace into a mark block (heading with embedded \\n\\n separator)', () => { + // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. + // mergeBlocks must detect this and strip the separator from inside the mark. + + it('should reduce block count when Backspace at start of block after heading mark', async () => { + const {container} = await render() + const before = getBlocks(container).length + + // block[1] is "This is a powerful..." which follows the heading mark (block[0]) + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Backspace}') + + expect(getBlocks(container)).toHaveLength(before - 1) + }) + + it('should preserve content of both blocks after merging into heading mark', async () => { + const {container} = await render() + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Backspace}') + + const raw = getRawValue(container) + expect(raw).toContain('Marked Input') + expect(raw).toContain('powerful') + }) + + it('should keep focus in the heading block after Backspace merge', async () => { + const {container} = await render() + const headingBlock = getBlocks(container)[0] + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Backspace}') + + expect(document.activeElement).toBe(headingBlock) + }) + }) }) describe('Delete merge blocks', () => { @@ -659,6 +696,86 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(5) }) + + describe('Delete at start of block', () => { + it('should merge with previous block when Delete pressed at start of non-first block', async () => { + const {container} = await render() + const before = getBlocks(container).length + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + expect(getBlocks(container)).toHaveLength(before - 1) + }) + + it('should preserve content of both merged blocks', async () => { + const {container} = await render() + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + const raw = getRawValue(container) + expect(raw).toContain('First block of plain text') + expect(raw).toContain('Second block of plain text') + }) + + it('should keep focus in the previous block after Delete merge', async () => { + const {container} = await render() + const prevBlock = getBlocks(container)[0] + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + expect(document.activeElement).toBe(prevBlock) + }) + + it('should not merge when Delete pressed at start of first block', async () => { + const {container} = await render() + const before = getBlocks(container).length + + await focusAtStart(getEditableInBlock(getBlocks(container)[0])) + await userEvent.keyboard('{Delete}') + + expect(getBlocks(container)).toHaveLength(before) + }) + }) + + describe('Delete into a mark block (heading with embedded \\n\\n separator)', () => { + // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. + // mergeBlocks must detect this and strip the separator from inside the mark. + + it('should reduce block count when Delete at start of block after heading mark', async () => { + const {container} = await render() + const before = getBlocks(container).length + + // block[1] is "This is a powerful..." which follows the heading mark (block[0]) + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + expect(getBlocks(container)).toHaveLength(before - 1) + }) + + it('should preserve content of both blocks after merging into heading mark', async () => { + const {container} = await render() + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + const raw = getRawValue(container) + expect(raw).toContain('Marked Input') + expect(raw).toContain('powerful') + }) + + it('should keep focus in the heading block after Delete merge', async () => { + const {container} = await render() + const headingBlock = getBlocks(container)[0] + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + expect(document.activeElement).toBe(headingBlock) + }) + }) }) describe('typing in blocks (BUG3)', () => { diff --git a/packages/vue/storybook/src/pages/Block/Block.spec.ts b/packages/vue/storybook/src/pages/Block/Block.spec.ts index b5936707..68441b65 100644 --- a/packages/vue/storybook/src/pages/Block/Block.spec.ts +++ b/packages/vue/storybook/src/pages/Block/Block.spec.ts @@ -556,6 +556,43 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(before - 1) }) + describe('Backspace into a mark block (heading with embedded \\n\\n separator)', () => { + // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. + // mergeBlocks must detect this and strip the separator from inside the mark. + + it('should reduce block count when Backspace at start of block after heading mark', async () => { + const {container} = await render(MarkdownDocument) + const before = getBlocks(container).length + + // block[1] is "This is a powerful..." which follows the heading mark (block[0]) + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Backspace}') + + expect(getBlocks(container)).toHaveLength(before - 1) + }) + + it('should preserve content of both blocks after merging into heading mark', async () => { + const {container} = await render(MarkdownDocument) + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Backspace}') + + const raw = getRawValue(container) + expect(raw).toContain('Marked Input') + expect(raw).toContain('powerful') + }) + + it('should keep focus in the heading block after Backspace merge', async () => { + const {container} = await render(MarkdownDocument) + const headingBlock = getBlocks(container)[0] + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Backspace}') + + expect(document.activeElement).toBe(headingBlock) + }) + }) + it('should preserve content of both merged blocks', async () => { const {container} = await render(PlainTextBlocks) @@ -687,6 +724,43 @@ describe('Feature: block keyboard navigation', () => { expect(raw).toContain('First block of plain textSecond block of plain text') }) }) + + describe('Delete into a mark block (heading with embedded \\n\\n separator)', () => { + // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. + // mergeBlocks must detect this and strip the separator from inside the mark. + + it('should reduce block count when Delete at start of block after heading mark', async () => { + const {container} = await render(MarkdownDocument) + const before = getBlocks(container).length + + // block[1] is "This is a powerful..." which follows the heading mark (block[0]) + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + expect(getBlocks(container)).toHaveLength(before - 1) + }) + + it('should preserve content of both blocks after merging into heading mark', async () => { + const {container} = await render(MarkdownDocument) + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + const raw = getRawValue(container) + expect(raw).toContain('Marked Input') + expect(raw).toContain('powerful') + }) + + it('should keep focus in the heading block after Delete merge', async () => { + const {container} = await render(MarkdownDocument) + const headingBlock = getBlocks(container)[0] + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Delete}') + + expect(document.activeElement).toBe(headingBlock) + }) + }) }) describe('typing in blocks', () => { From 08b4e04a3ca70d683374ade29a6e58e039bc1f54 Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 11 Mar 2026 13:53:20 +0300 Subject: [PATCH 3/3] feat(stories): add TodoList component with Notion-like checklist functionality - Introduced a new `TodoList` story showcasing a Notion-like checklist with nested hierarchy. - Created `TodoMark` component to render checklist items with visual indicators for pending and completed tasks. - Defined `todoOptions` for customizable rendering of checklist items based on their status. - Added a sample checklist value to demonstrate the component's functionality in the story. This enhances the storybook by providing a practical example of a checklist feature, improving the overall user experience. --- .../src/pages/Block/Block.stories.tsx | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/packages/react/storybook/src/pages/Block/Block.stories.tsx b/packages/react/storybook/src/pages/Block/Block.stories.tsx index 326e53ff..eea6420a 100644 --- a/packages/react/storybook/src/pages/Block/Block.stories.tsx +++ b/packages/react/storybook/src/pages/Block/Block.stories.tsx @@ -1,6 +1,7 @@ import {MarkedInput} from '@markput/react' +import type {MarkProps, Option} from '@markput/react' import type {Meta, StoryObj} from '@storybook/react-vite' -import type {ReactNode} from 'react' +import type {CSSProperties, ReactNode} from 'react' import {useState} from 'react' import {Text} from '../../shared/components/Text' @@ -155,4 +156,151 @@ Fifth block of plain text`) ) }, +} + +// --------------------------------------------------------------------------- +// Todo List — Notion-like checklist with nested hierarchy +// --------------------------------------------------------------------------- + +interface TodoMarkProps extends MarkProps { + style?: CSSProperties + todo?: 'pending' | 'done' +} + +const todoOptions: Option[] = [ + { + markup: '# __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '1.4em', fontWeight: 'bold', margin: '0.3em 0'} as CSSProperties, + }), + }, + { + markup: '- [ ] __nested__\n\n', + mark: (props: MarkProps) => ({...props, todo: 'pending' as const, style: {display: 'block'} as CSSProperties}), + }, + { + markup: '- [x] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'done' as const, + style: {display: 'block', textDecoration: 'line-through', opacity: 0.5} as CSSProperties, + }), + }, + { + markup: '\t- [ ] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'pending' as const, + style: {display: 'block', paddingLeft: '1.5em'} as CSSProperties, + }), + }, + { + markup: '\t- [x] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'done' as const, + style: { + display: 'block', + paddingLeft: '1.5em', + textDecoration: 'line-through', + opacity: 0.5, + } as CSSProperties, + }), + }, + { + markup: '\t\t- [ ] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'pending' as const, + style: {display: 'block', paddingLeft: '3em'} as CSSProperties, + }), + }, + { + markup: '\t\t- [x] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'done' as const, + style: { + display: 'block', + paddingLeft: '3em', + textDecoration: 'line-through', + opacity: 0.5, + } as CSSProperties, + }), + }, + { + markup: '> __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '0.85em', color: '#888', fontStyle: 'italic'} as CSSProperties, + }), + }, +] + +const TodoMark = ({children, value, style, todo}: TodoMarkProps) => ( + + {todo && {todo === 'done' ? '\u2611' : '\u2610'}} + {children || value} + +) + +const TODO_VALUE = `# \u{1F4CB} Project Launch Checklist + +- [ ] Design Phase + +\t- [ ] Create wireframes + +\t- [x] Define color palette + +\t- [ ] Design component library + +- [x] Research + +\t- [x] Analyze competitors + +\t- [x] User interviews + +\t\t- [x] Draft interview questions + +\t\t- [x] Schedule 5 sessions + +- [ ] Development + +\t- [ ] Set up CI/CD pipeline + +\t- [x] Write unit tests + +\t- [ ] API integration + +\t\t- [ ] Auth endpoints + +\t\t- [ ] Data sync + +- [ ] Launch + +\t- [ ] Final QA pass + +\t- [ ] Deploy to production + +> \u2610 = pending \u2611 = done` + +export const TodoList: Story = { + render: () => { + const [value, setValue] = useState(TODO_VALUE) + + return ( +
+ + +
+ ) + }, } \ No newline at end of file