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)
+ })
})
})