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 d3f898b3..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 @@ -159,7 +160,28 @@ 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 = getMergeJoinPos(blocks, blockIndex) + 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 updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex - 1] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) + } + }) + return + } // Caret at end of non-last block: merge next block into current if (caretAtEnd && blockIndex < blocks.length - 1) { @@ -172,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 @@ -527,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() @@ -545,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 @@ -560,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/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 diff --git a/packages/vue/storybook/src/pages/Block/Block.spec.ts b/packages/vue/storybook/src/pages/Block/Block.spec.ts index fd48c7f2..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) @@ -590,46 +627,139 @@ 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] + + await focusAtEnd(getEditableInBlock(currentBlock)) + await userEvent.keyboard('{Delete}') + + 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 keep focus in the current block after Delete merge', async () => { - const {container} = await render(PlainTextBlocks) - const currentBlock = getBlocks(container)[0] + 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 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(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) + }) - await focusAtEnd(getEditableInBlock(currentBlock)) - await userEvent.keyboard('{Delete}') + it('should place caret at the join point after merge', async () => { + const {container} = await render(PlainTextBlocks) - expect(document.activeElement).toBe(currentBlock) + 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') + }) }) - 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 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. - await focusAtEnd(getEditableInBlock(last)) - await userEvent.keyboard('{Delete}') + 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 - expect(getBlocks(container)).toHaveLength(5) + // 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) + }) }) })