From f96894a7d7134a1017554a9290c343e491290026 Mon Sep 17 00:00:00 2001 From: Nowely Date: Sat, 7 Mar 2026 18:08:15 +0300 Subject: [PATCH 01/25] feat(blocks): enhance block operations and UI interactions - Introduced new block operations: addBlock, deleteBlock, and duplicateBlock for improved block management. - Updated BlockContainer and DraggableBlock components to support new operations with corresponding UI interactions. - Enhanced splitTokensIntoBlocks function to handle trailing newlines as empty blocks. - Updated Storybook documentation to reflect new functionality and UI changes for draggable blocks. - Improved test coverage for block operations and UI interactions in both React and Vue components. --- packages/common/core/index.ts | 9 +- .../src/features/blocks/blockOperations.ts | 34 ++ .../common/core/src/features/blocks/index.ts | 3 +- .../src/features/blocks/reorderBlocks.spec.ts | 7 +- .../blocks/splitTokensIntoBlocks.spec.ts | 22 +- .../features/blocks/splitTokensIntoBlocks.ts | 33 +- .../markput/src/components/BlockContainer.tsx | 60 ++- .../markput/src/components/DraggableBlock.tsx | 398 ++++++++++++------ .../DraggableBlocks.stories.tsx | 10 +- .../markput/src/components/BlockContainer.vue | 35 +- .../markput/src/components/DraggableBlock.vue | 263 +++++++++--- .../DraggableBlocks.stories.ts | 4 +- pnpm-lock.yaml | 6 + 13 files changed, 649 insertions(+), 235 deletions(-) create mode 100644 packages/common/core/src/features/blocks/blockOperations.ts diff --git a/packages/common/core/index.ts b/packages/common/core/index.ts index 0718f56e..efa4de0c 100644 --- a/packages/common/core/index.ts +++ b/packages/common/core/index.ts @@ -85,7 +85,14 @@ export {Lifecycle, type LifecycleOptions} from './src/features/lifecycle' export {MarkHandler, type MarkOptions, type RefAccessor} from './src/features/mark' // Blocks -export {splitTokensIntoBlocks, reorderBlocks, type Block} from './src/features/blocks' +export { + splitTokensIntoBlocks, + reorderBlocks, + addBlock, + deleteBlock, + duplicateBlock, + type Block, +} from './src/features/blocks' // Navigation & Input export {shiftFocusPrev, shiftFocusNext} from './src/features/navigation' diff --git a/packages/common/core/src/features/blocks/blockOperations.ts b/packages/common/core/src/features/blocks/blockOperations.ts new file mode 100644 index 00000000..b451ab5b --- /dev/null +++ b/packages/common/core/src/features/blocks/blockOperations.ts @@ -0,0 +1,34 @@ +import type {Block} from './splitTokensIntoBlocks' + +export function addBlock(value: string, blocks: Block[], afterIndex: number): string { + if (blocks.length === 0) return value + '\n' + + if (afterIndex >= blocks.length - 1) { + return value + '\n' + } + + const insertPos = blocks[afterIndex + 1].startPos + return value.slice(0, insertPos) + '\n' + value.slice(insertPos) +} + +export function deleteBlock(value: string, blocks: Block[], index: number): string { + if (blocks.length <= 1) return '' + + if (index >= blocks.length - 1) { + return value.slice(0, blocks[index - 1].endPos) + } + + return value.slice(0, blocks[index].startPos) + value.slice(blocks[index + 1].startPos) +} + +export function duplicateBlock(value: string, blocks: Block[], index: number): string { + const block = blocks[index] + const blockText = value.substring(block.startPos, block.endPos) + + if (index >= blocks.length - 1) { + return value + '\n' + blockText + } + + const insertPos = blocks[index + 1].startPos + return value.slice(0, insertPos) + blockText + '\n' + value.slice(insertPos) +} \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/index.ts b/packages/common/core/src/features/blocks/index.ts index d31baaa1..18c8d654 100644 --- a/packages/common/core/src/features/blocks/index.ts +++ b/packages/common/core/src/features/blocks/index.ts @@ -1,2 +1,3 @@ export {splitTokensIntoBlocks, type Block} from './splitTokensIntoBlocks' -export {reorderBlocks} from './reorderBlocks' \ No newline at end of file +export {reorderBlocks} from './reorderBlocks' +export {addBlock, deleteBlock, duplicateBlock} from './blockOperations' \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/reorderBlocks.spec.ts b/packages/common/core/src/features/blocks/reorderBlocks.spec.ts index 99fde964..137838f2 100644 --- a/packages/common/core/src/features/blocks/reorderBlocks.spec.ts +++ b/packages/common/core/src/features/blocks/reorderBlocks.spec.ts @@ -150,17 +150,18 @@ describe('reorderBlocks round-trip (reorder → re-parse → re-split)', () => { expect(reordered.split('\n')).toHaveLength(3) }) - it('does not preserve trailing newline from original after reorder', () => { + it('trailing newline creates empty block and is preserved after reorder', () => { const original = 'aaa\nbbb\nccc\n' const parser = new Parser([]) const tokens = parser.parse(original) const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) + // Trailing newline now creates an empty block + expect(blocks).toHaveLength(4) expect(blocks[2].endPos).toBe(11) const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\naaa\nccc') + expect(reordered).toBe('bbb\naaa\nccc\n') }) it('handles unicode content correctly', () => { diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts index ae2e3932..4a3fb2a1 100644 --- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts +++ b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts @@ -76,12 +76,23 @@ describe('splitTokensIntoBlocks', () => { expect(blocks[2].endPos).toBe(11) }) - it('handles consecutive newlines (empty lines)', () => { + it('handles consecutive newlines (empty lines) as separate blocks', () => { const tokens: Token[] = [text('aaa\n\nbbb', 0)] const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(2) + expect(blocks).toHaveLength(3) + expect((blocks[0].tokens[0] as TextToken).content).toBe('aaa') + expect(blocks[1].tokens).toHaveLength(0) + expect((blocks[2].tokens[0] as TextToken).content).toBe('bbb') + }) + + it('creates empty block from trailing newline', () => { + const tokens: Token[] = [text('aaa\nbbb\n', 0)] + const blocks = splitTokensIntoBlocks(tokens) + expect(blocks).toHaveLength(3) expect((blocks[0].tokens[0] as TextToken).content).toBe('aaa') expect((blocks[1].tokens[0] as TextToken).content).toBe('bbb') + expect(blocks[2].tokens).toHaveLength(0) + expect(blocks[2].startPos).toBe(blocks[2].endPos) }) it('generates unique block ids based on start position', () => { @@ -100,7 +111,12 @@ describe('splitTokensIntoBlocks', () => { it('handles text with only newlines', () => { const tokens: Token[] = [text('\n\n\n', 0)] const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(0) + // Three newlines produce empty blocks after each (except the leading one before content) + expect(blocks).toHaveLength(3) + blocks.forEach(b => { + expect(b.tokens).toHaveLength(0) + expect(b.startPos).toBe(b.endPos) + }) }) it('handles \\r\\n line endings (Windows)', () => { diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts index 2ae7d8da..b306846b 100644 --- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts +++ b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts @@ -25,17 +25,25 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { const blocks: Block[] = [] let currentTokens: Token[] = [] let blockStart = -1 - - const flushBlock = (endPos: number) => { - if (currentTokens.length === 0) return + // Tracks whether blockStart was set by a text newline (vs a mark-newline block). + // Only text-newline-derived pending positions can produce trailing empty blocks. + let blockStartFromText = false + + const flushBlock = (endPos: number, canCreateEmpty = false) => { + const isEmpty = currentTokens.length === 0 + if (blockStart === -1 && isEmpty) return + // Don't create empty blocks when triggered by a new mark (e.g. between consecutive marks) + if (isEmpty && !canCreateEmpty) return + const startPos = blockStart === -1 ? endPos : blockStart blocks.push({ - id: generateBlockId(blockStart), + id: generateBlockId(startPos), tokens: [...currentTokens], - startPos: blockStart, - endPos, + startPos, + endPos: isEmpty ? startPos : endPos, }) currentTokens = [] blockStart = -1 + blockStartFromText = false } for (const token of tokens) { @@ -51,6 +59,8 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { startPos: token.position.start, endPos: token.position.end, }) + blockStart = token.position.end + blockStartFromText = false } else { if (blockStart === -1) blockStart = token.position.start currentTokens.push(token) @@ -67,7 +77,9 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { const part = parts[i] if (part.isNewline) { - flushBlock(part.position.start) + flushBlock(part.position.start, true) + blockStart = part.position.end + blockStartFromText = true continue } @@ -82,9 +94,10 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { } } - if (currentTokens.length > 0) { - const lastToken = currentTokens[currentTokens.length - 1] - flushBlock(lastToken.position.end) + const lastPos = currentTokens.length > 0 ? currentTokens[currentTokens.length - 1].position.end : blockStart + if (blockStart !== -1 || currentTokens.length > 0) { + // Allow empty block at end only when the trailing newline came from a text token + flushBlock(lastPos === -1 ? 0 : lastPos, currentTokens.length > 0 || blockStartFromText) } return blocks diff --git a/packages/react/markput/src/components/BlockContainer.tsx b/packages/react/markput/src/components/BlockContainer.tsx index 18b4486d..a77f3803 100644 --- a/packages/react/markput/src/components/BlockContainer.tsx +++ b/packages/react/markput/src/components/BlockContainer.tsx @@ -3,6 +3,9 @@ import { resolveSlotProps, splitTokensIntoBlocks, reorderBlocks, + addBlock, + deleteBlock, + duplicateBlock, parseWithParser, type Block, } from '@markput/core' @@ -33,19 +36,48 @@ export const BlockContainer = memo(() => { const blocksRef = useRef(blocks) blocksRef.current = blocks + const applyNewValue = useCallback( + (newValue: string) => { + if (!onChange) return + const newTokens = parseWithParser(store, newValue) + store.state.tokens.set(newTokens) + store.state.previousValue.set(newValue) + onChange(newValue) + }, + [store, onChange] + ) + const handleReorder = useCallback( (sourceIndex: number, targetIndex: number) => { if (!value || !onChange) return - const currentBlocks = blocksRef.current - const newValue = reorderBlocks(value, currentBlocks, sourceIndex, targetIndex) - if (newValue !== value) { - const newTokens = parseWithParser(store, newValue) - store.state.tokens.set(newTokens) - store.state.previousValue.set(newValue) - onChange(newValue) - } + const newValue = reorderBlocks(value, blocksRef.current, sourceIndex, targetIndex) + if (newValue !== value) applyNewValue(newValue) + }, + [value, onChange, applyNewValue] + ) + + const handleAdd = useCallback( + (afterIndex: number) => { + if (!value || !onChange) return + applyNewValue(addBlock(value, blocksRef.current, afterIndex)) + }, + [value, onChange, applyNewValue] + ) + + const handleDelete = useCallback( + (index: number) => { + if (!value || !onChange) return + applyNewValue(deleteBlock(value, blocksRef.current, index)) + }, + [value, onChange, applyNewValue] + ) + + const handleDuplicate = useCallback( + (index: number) => { + if (!value || !onChange) return + applyNewValue(duplicateBlock(value, blocksRef.current, index)) }, - [store, value, onChange] + [value, onChange, applyNewValue] ) return ( @@ -56,7 +88,15 @@ export const BlockContainer = memo(() => { style={style} > {blocks.map((block, index) => ( - + {block.tokens.map(token => ( ))} diff --git a/packages/react/markput/src/components/DraggableBlock.tsx b/packages/react/markput/src/components/DraggableBlock.tsx index 3ce22077..742dc509 100644 --- a/packages/react/markput/src/components/DraggableBlock.tsx +++ b/packages/react/markput/src/components/DraggableBlock.tsx @@ -1,62 +1,14 @@ -import type {ReactNode, DragEvent, CSSProperties} from 'react' -import {memo, useCallback, useRef, useState} from 'react' +import type {ReactNode, DragEvent, CSSProperties, MouseEvent} from 'react' +import {memo, useCallback, useRef, useState, useEffect} from 'react' interface DraggableBlockProps { blockIndex: number children: ReactNode readOnly: boolean onReorder: (sourceIndex: number, targetIndex: number) => void -} - -const HANDLE_STYLES: CSSProperties = { - position: 'absolute', - left: -28, - top: 2, - width: 20, - height: 20, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'grab', - borderRadius: 4, - opacity: 0, - transition: 'opacity 0.15s ease', - userSelect: 'none', - color: '#9ca3af', - flexShrink: 0, - background: 'none', - border: 'none', - padding: 0, - margin: 0, - font: 'inherit', - lineHeight: 1, -} - -const HANDLE_VISIBLE_STYLES: CSSProperties = { - ...HANDLE_STYLES, - opacity: 1, -} - -const HANDLE_DRAGGING_STYLES: CSSProperties = { - ...HANDLE_VISIBLE_STYLES, - cursor: 'grabbing', -} - -const BLOCK_STYLES: CSSProperties = { - position: 'relative', - paddingLeft: 4, - transition: 'opacity 0.2s ease', -} - -const DROP_INDICATOR_STYLES: CSSProperties = { - position: 'absolute', - left: 0, - right: 0, - height: 2, - backgroundColor: '#3b82f6', - borderRadius: 1, - pointerEvents: 'none', - zIndex: 10, + onAdd?: (afterIndex: number) => void + onDelete?: (index: number) => void + onDuplicate?: (index: number) => void } const GripIcon = memo(() => ( @@ -74,103 +26,287 @@ GripIcon.displayName = 'GripIcon' type DropPosition = 'before' | 'after' | null -export const DraggableBlock = memo(({blockIndex, children, readOnly, onReorder}: DraggableBlockProps) => { - const [isHovered, setIsHovered] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const [dropPosition, setDropPosition] = useState(null) - const blockRef = useRef(null) +interface MenuPosition { + top: number + left: number +} - const handleMouseEnter = useCallback(() => setIsHovered(true), []) - const handleMouseLeave = useCallback(() => setIsHovered(false), []) +interface BlockMenuProps { + position: MenuPosition + onDelete: () => void + onDuplicate: () => void + onClose: () => void +} - const handleDragStart = useCallback( - (e: DragEvent) => { - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', String(blockIndex)) - setIsDragging(true) +const BlockMenu = memo(({position, onDelete, onDuplicate, onClose}: BlockMenuProps) => { + const menuRef = useRef(null) + const [hoveredItem, setHoveredItem] = useState(null) - if (blockRef.current) { - e.dataTransfer.setDragImage(blockRef.current, 0, 0) + useEffect(() => { + const handleMouseDown = (e: globalThis.MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose() } - }, - [blockIndex] + } + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('mousedown', handleMouseDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleMouseDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + const menuStyle: CSSProperties = { + position: 'fixed', + top: position.top, + left: position.left, + background: 'white', + border: '1px solid rgba(55, 53, 47, 0.16)', + borderRadius: 6, + boxShadow: '0 4px 16px rgba(15, 15, 15, 0.12)', + padding: 4, + zIndex: 9999, + minWidth: 160, + fontSize: 14, + } + + const itemStyle = (key: string): CSSProperties => ({ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 10px', + borderRadius: 4, + cursor: 'pointer', + color: key === 'delete' ? '#eb5757' : 'inherit', + background: + hoveredItem === key + ? key === 'delete' + ? 'rgba(235, 87, 87, 0.06)' + : 'rgba(55, 53, 47, 0.06)' + : 'transparent', + userSelect: 'none', + }) + + return ( +
+
setHoveredItem('duplicate')} + onMouseLeave={() => setHoveredItem(null)} + onMouseDown={e => { + e.preventDefault() + onDuplicate() + onClose() + }} + > + + Duplicate +
+
setHoveredItem('delete')} + onMouseLeave={() => setHoveredItem(null)} + onMouseDown={e => { + e.preventDefault() + onDelete() + onClose() + }} + > + 🗑 + Delete +
+
) +}) - const handleDragEnd = useCallback(() => { - setIsDragging(false) - setDropPosition(null) - }, []) +BlockMenu.displayName = 'BlockMenu' - const handleDragOver = useCallback((e: DragEvent) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' +export const DraggableBlock = memo( + ({blockIndex, children, readOnly, onReorder, onAdd, onDelete, onDuplicate}: DraggableBlockProps) => { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [dropPosition, setDropPosition] = useState(null) + const [menuPosition, setMenuPosition] = useState(null) + const blockRef = useRef(null) + const gripRef = useRef(null) - if (!blockRef.current) return + const handleMouseEnter = useCallback(() => setIsHovered(true), []) + const handleMouseLeave = useCallback(() => setIsHovered(false), []) - const rect = blockRef.current.getBoundingClientRect() - const midY = rect.top + rect.height / 2 - setDropPosition(e.clientY < midY ? 'before' : 'after') - }, []) + const handleDragStart = useCallback( + (e: DragEvent) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', String(blockIndex)) + setIsDragging(true) - const handleDragLeave = useCallback((e: DragEvent) => { - if (e.currentTarget.contains(e.relatedTarget as Node)) return - setDropPosition(null) - }, []) + if (blockRef.current) { + e.dataTransfer.setDragImage(blockRef.current, 0, 0) + } + }, + [blockIndex] + ) - const handleDrop = useCallback( - (e: DragEvent) => { + const handleDragEnd = useCallback(() => { + setIsDragging(false) + setDropPosition(null) + }, []) + + const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault() - const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'), 10) - if (isNaN(sourceIndex)) return + e.dataTransfer.dropEffect = 'move' + + if (!blockRef.current) return - const targetIndex = dropPosition === 'before' ? blockIndex : blockIndex + 1 + const rect = blockRef.current.getBoundingClientRect() + const midY = rect.top + rect.height / 2 + setDropPosition(e.clientY < midY ? 'before' : 'after') + }, []) + + const handleDragLeave = useCallback((e: DragEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) return setDropPosition(null) - onReorder(sourceIndex, targetIndex) - }, - [blockIndex, dropPosition, onReorder] - ) + }, []) - const blockStyle: CSSProperties = { - ...BLOCK_STYLES, - opacity: isDragging ? 0.4 : 1, - } + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault() + const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'), 10) + if (isNaN(sourceIndex)) return - const handleStyle = readOnly - ? {...HANDLE_STYLES, display: 'none'} - : isDragging - ? HANDLE_DRAGGING_STYLES - : isHovered - ? HANDLE_VISIBLE_STYLES - : HANDLE_STYLES + const targetIndex = dropPosition === 'before' ? blockIndex : blockIndex + 1 + setDropPosition(null) + onReorder(sourceIndex, targetIndex) + }, + [blockIndex, dropPosition, onReorder] + ) - return ( -
- {dropPosition === 'before' &&
} - - + {dropPosition === 'before' &&
} - {children} + {!readOnly && ( +
+ + +
+ )} - {dropPosition === 'after' &&
} -
- ) -}) + {children ||
} + + {dropPosition === 'after' &&
} + + {menuPosition && onDelete && onDuplicate && ( + onDelete(blockIndex)} + onDuplicate={() => onDuplicate(blockIndex)} + onClose={() => setMenuPosition(null)} + /> + )} +
+ ) + } +) DraggableBlock.displayName = 'DraggableBlock' \ No newline at end of file diff --git a/packages/react/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.tsx b/packages/react/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.tsx index fe9f67f3..8414407d 100644 --- a/packages/react/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.tsx +++ b/packages/react/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.tsx @@ -14,7 +14,7 @@ export default { docs: { description: { component: - 'Notion-like draggable blocks. Hover over a block to reveal the drag handle on the left, then drag to reorder.', + 'Notion-like draggable blocks. Hover over a block to reveal the + and drag handle buttons. Drag to reorder, click + to add a block, click the grip to open a block menu (delete/duplicate).', }, }, }, @@ -45,7 +45,7 @@ This is the second paragraph. Try dragging it above the first one! ) return ( -
+
+
+
+
import type {Token as CoreToken} from '@markput/core' -import {resolveSlot, resolveSlotProps, splitTokensIntoBlocks, reorderBlocks, parseWithParser} from '@markput/core' +import {resolveSlot, resolveSlotProps, splitTokensIntoBlocks, reorderBlocks, addBlock, deleteBlock, duplicateBlock, parseWithParser} from '@markput/core' import type {Component} from 'vue' import {computed} from 'vue' @@ -24,15 +24,33 @@ const containerProps = computed(() => resolveSlotProps('container', slotProps.va const blocks = computed(() => splitTokensIntoBlocks(tokens.value)) +function applyNewValue(newValue: string) { + if (!onChange.value) return + const newTokens = parseWithParser(store, newValue) + store.state.tokens.set(newTokens) + store.state.previousValue.set(newValue) + onChange.value(newValue) +} + function handleReorder(sourceIndex: number, targetIndex: number) { if (!value.value || !onChange.value) return const newValue = reorderBlocks(value.value, blocks.value, sourceIndex, targetIndex) - if (newValue !== value.value) { - const newTokens = parseWithParser(store, newValue) - store.state.tokens.set(newTokens) - store.state.previousValue.set(newValue) - onChange.value(newValue) - } + if (newValue !== value.value) applyNewValue(newValue) +} + +function handleAdd(afterIndex: number) { + if (!value.value || !onChange.value) return + applyNewValue(addBlock(value.value, blocks.value, afterIndex)) +} + +function handleDelete(index: number) { + if (!value.value || !onChange.value) return + applyNewValue(deleteBlock(value.value, blocks.value, index)) +} + +function handleDuplicate(index: number) { + if (!value.value || !onChange.value) return + applyNewValue(duplicateBlock(value.value, blocks.value, index)) } @@ -50,6 +68,9 @@ function handleReorder(sourceIndex: number, targetIndex: number) { :block-index="index" :read-only="readOnly" @reorder="handleReorder" + @add="handleAdd" + @delete="handleDelete" + @duplicate="handleDuplicate" > diff --git a/packages/vue/markput/src/components/DraggableBlock.vue b/packages/vue/markput/src/components/DraggableBlock.vue index 85836f17..2fc12a92 100644 --- a/packages/vue/markput/src/components/DraggableBlock.vue +++ b/packages/vue/markput/src/components/DraggableBlock.vue @@ -1,5 +1,5 @@ - @@ -115,28 +220,62 @@ const DROP_INDICATOR_STYLES: CSSProperties = { @dragleave="onDragLeave" @drop="onDrop" > -
- - - - - -
+
+ +
+ + +
+ +
+ +
+ + +
+
+ + Duplicate +
+
+ 🗑 + Delete +
+
+
diff --git a/packages/vue/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.ts b/packages/vue/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.ts index aadc1bd7..a774db70 100644 --- a/packages/vue/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.ts +++ b/packages/vue/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.ts @@ -13,7 +13,7 @@ export default { docs: { description: { component: - 'Notion-like draggable blocks. Hover over a block to reveal the drag handle on the left, then drag to reorder.', + 'Notion-like draggable blocks. Hover over a block to reveal the + and drag handle buttons. Drag to reorder, click + to add a block, click the grip to open a block menu (delete/duplicate).', }, }, }, @@ -76,7 +76,7 @@ const markdownOptions: Option[] = [ }, ] -const containerStyle = {maxWidth: '700px', margin: '0 auto', paddingLeft: '32px'} +const containerStyle = {maxWidth: '700px', margin: '0 auto', paddingLeft: '52px'} const editorStyle = {minHeight: '200px', padding: '12px', border: '1px solid #e0e0e0', borderRadius: '8px'} export const BasicDraggable: Story = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf787faa..29f71ace 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,12 +175,18 @@ importers: '@types/react': specifier: 'catalog:' version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react-swc': specifier: 'catalog:' version: 4.2.3(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) react: specifier: 'catalog:' version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) typescript: specifier: 'catalog:' version: 5.9.3 From 5834daf83268809f6cce933906dc1e09f6b09fcb Mon Sep 17 00:00:00 2001 From: Nowely Date: Sat, 7 Mar 2026 18:19:32 +0300 Subject: [PATCH 02/25] fix(pnpm): update package links to point to distribution directories - Changed package version links for '@markput/react', '@markput/vue', and '@markput/react' in pnpm-lock.yaml to reference the 'dist' directories instead of the root. - Added new package entries for 'packages/react/markput/dist' and 'packages/vue/markput/dist' with their respective dependencies. - Introduced a new test file for block operations, enhancing test coverage for add, delete, and duplicate block functionalities. --- .../features/blocks/blockOperations.spec.ts | 80 +++++++++++++++++++ .../markput/src/components/DraggableBlock.tsx | 4 +- pnpm-lock.yaml | 31 ++++--- 3 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 packages/common/core/src/features/blocks/blockOperations.spec.ts diff --git a/packages/common/core/src/features/blocks/blockOperations.spec.ts b/packages/common/core/src/features/blocks/blockOperations.spec.ts new file mode 100644 index 00000000..018e944b --- /dev/null +++ b/packages/common/core/src/features/blocks/blockOperations.spec.ts @@ -0,0 +1,80 @@ +import {describe, expect, it} from 'vitest' + +import {addBlock, deleteBlock, duplicateBlock} from './blockOperations' +import type {Block} from './splitTokensIntoBlocks' + +function makeBlock(id: string, startPos: number, endPos: number): Block { + return {id, tokens: [], startPos, endPos} +} + +// "A\nB\nC" → blocks at [0,1], [2,3], [4,5] +const THREE_BLOCKS: Block[] = [makeBlock('0', 0, 1), makeBlock('2', 2, 3), makeBlock('4', 4, 5)] + +describe('addBlock', () => { + it('appends newline when blocks is empty', () => { + expect(addBlock('A', [], 0)).toBe('A\n') + }) + + it('appends newline when afterIndex is last block', () => { + expect(addBlock('A\nB\nC', THREE_BLOCKS, 2)).toBe('A\nB\nC\n') + }) + + it('inserts newline after middle block', () => { + // After block[0] ("A"), insert before block[1] start (pos 2) + expect(addBlock('A\nB\nC', THREE_BLOCKS, 0)).toBe('A\n\nB\nC') + }) + + it('inserts newline after first block in two-block value', () => { + const blocks: Block[] = [makeBlock('0', 0, 1), makeBlock('2', 2, 3)] + expect(addBlock('A\nB', blocks, 0)).toBe('A\n\nB') + }) +}) + +describe('deleteBlock', () => { + it('returns empty string when only one block', () => { + const blocks: Block[] = [makeBlock('0', 0, 1)] + expect(deleteBlock('A', blocks, 0)).toBe('') + }) + + it('deletes first block', () => { + // Remove block[0] ("A\n"), remainder is "B\nC" + expect(deleteBlock('A\nB\nC', THREE_BLOCKS, 0)).toBe('B\nC') + }) + + it('deletes middle block', () => { + // Remove block[1] ("B\n"), result is "A\nC" + expect(deleteBlock('A\nB\nC', THREE_BLOCKS, 1)).toBe('A\nC') + }) + + it('deletes last block', () => { + // Remove block[2] ("C"), trim to block[1].endPos (3) + expect(deleteBlock('A\nB\nC', THREE_BLOCKS, 2)).toBe('A\nB') + }) + + it('deletes from a two-block value', () => { + const blocks: Block[] = [makeBlock('0', 0, 1), makeBlock('2', 2, 3)] + expect(deleteBlock('A\nB', blocks, 0)).toBe('B') + expect(deleteBlock('A\nB', blocks, 1)).toBe('A') + }) +}) + +describe('duplicateBlock', () => { + it('duplicates last block by appending', () => { + expect(duplicateBlock('A\nB\nC', THREE_BLOCKS, 2)).toBe('A\nB\nC\nC') + }) + + it('duplicates first block into middle', () => { + // Insert "A\n" before block[1] start (pos 2) → "A\nA\nB\nC" + expect(duplicateBlock('A\nB\nC', THREE_BLOCKS, 0)).toBe('A\nA\nB\nC') + }) + + it('duplicates middle block', () => { + // Insert "B\n" before block[2] start (pos 4) → "A\nB\nB\nC" + expect(duplicateBlock('A\nB\nC', THREE_BLOCKS, 1)).toBe('A\nB\nB\nC') + }) + + it('duplicates single block', () => { + const blocks: Block[] = [makeBlock('0', 0, 1)] + expect(duplicateBlock('A', blocks, 0)).toBe('A\nA') + }) +}) \ No newline at end of file diff --git a/packages/react/markput/src/components/DraggableBlock.tsx b/packages/react/markput/src/components/DraggableBlock.tsx index 742dc509..95e3fe52 100644 --- a/packages/react/markput/src/components/DraggableBlock.tsx +++ b/packages/react/markput/src/components/DraggableBlock.tsx @@ -1,5 +1,5 @@ import type {ReactNode, DragEvent, CSSProperties, MouseEvent} from 'react' -import {memo, useCallback, useRef, useState, useEffect} from 'react' +import {Children, memo, useCallback, useRef, useState, useEffect} from 'react' interface DraggableBlockProps { blockIndex: number @@ -292,7 +292,7 @@ export const DraggableBlock = memo(
)} - {children ||
} + {Children.count(children) === 0 ?
: children} {dropPosition === 'after' &&
} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29f71ace..4ee89c3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,7 @@ importers: dependencies: '@markput/react': specifier: workspace:* - version: link:../markput + version: link:../markput/dist react: specifier: 'catalog:' version: 19.2.4 @@ -175,18 +175,12 @@ importers: '@types/react': specifier: 'catalog:' version: 19.2.14 - '@types/react-dom': - specifier: 'catalog:' - version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react-swc': specifier: 'catalog:' version: 4.2.3(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) react: specifier: 'catalog:' version: 19.2.4 - react-dom: - specifier: 'catalog:' - version: 19.2.4(react@19.2.4) typescript: specifier: 'catalog:' version: 5.9.3 @@ -194,6 +188,15 @@ importers: specifier: 'catalog:' version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + packages/react/markput/dist: + dependencies: + react: + specifier: '>=19.0.0' + version: 19.2.4 + react-dom: + specifier: '>=19.0.0' + version: 19.2.4(react@19.2.4) + packages/react/storybook: dependencies: '@emotion/react': @@ -207,7 +210,7 @@ importers: version: 10.3.0 '@markput/react': specifier: workspace:* - version: link:../markput + version: link:../markput/dist '@mui/material': specifier: ^7.3.7 version: 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -277,7 +280,7 @@ importers: dependencies: '@markput/vue': specifier: workspace:* - version: link:../markput + version: link:../markput/dist vue: specifier: 'catalog:' version: 3.5.29(typescript@5.9.3) @@ -320,11 +323,17 @@ importers: specifier: 'catalog:' version: 3.2.5(typescript@5.9.3) + packages/vue/markput/dist: + dependencies: + vue: + specifier: '>=3.5.0' + version: 3.5.29(typescript@5.9.3) + packages/vue/storybook: dependencies: '@markput/vue': specifier: workspace:* - version: link:../markput + version: link:../markput/dist vue: specifier: 'catalog:' version: 3.5.29(typescript@5.9.3) @@ -391,7 +400,7 @@ importers: version: 9.0.4(astro@5.18.0(@types/node@24.12.0)(@vercel/functions@2.2.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2))(react@19.2.4)(rollup@4.59.0)(vue@3.5.29(typescript@5.9.3)) '@markput/react': specifier: workspace:* - version: link:../react/markput + version: link:../react/markput/dist '@tailwindcss/vite': specifier: ^4.1.18 version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) From 12e94ef2e3d70920103d8c86f17b815795419812 Mon Sep 17 00:00:00 2001 From: Nowely Date: Sat, 7 Mar 2026 18:36:24 +0300 Subject: [PATCH 03/25] refactor(blocks): streamline block operations and enhance UI interactions - Removed redundant newline handling in addBlock function. - Added error handling for missing markup in createMarkFromOverlay function. - Updated filterSuggestions to use includes for better readability. - Refactored BlockContainer to integrate a new BlockMenu for block actions (duplicate, delete). - Enhanced DraggableBlock to support menu requests for block actions. - Improved Suggestions component to utilize refs for better performance during keyboard navigation. - Cleaned up various components by removing unused code and ensuring consistent formatting. --- .../src/features/blocks/blockOperations.ts | 2 - .../features/overlay/createMarkFromOverlay.ts | 4 +- .../src/features/overlay/filterSuggestions.ts | 2 +- .../src/shared/utils/resolveOptionSlot.ts | 2 +- .../markput/src/components/BlockContainer.tsx | 176 +++++++++++++++--- .../markput/src/components/DraggableBlock.tsx | 134 ++----------- .../components/Suggestions/Suggestions.tsx | 18 +- .../markput/src/components/DraggableBlock.vue | 42 +++-- 8 files changed, 207 insertions(+), 173 deletions(-) diff --git a/packages/common/core/src/features/blocks/blockOperations.ts b/packages/common/core/src/features/blocks/blockOperations.ts index b451ab5b..406c01f0 100644 --- a/packages/common/core/src/features/blocks/blockOperations.ts +++ b/packages/common/core/src/features/blocks/blockOperations.ts @@ -1,8 +1,6 @@ import type {Block} from './splitTokensIntoBlocks' export function addBlock(value: string, blocks: Block[], afterIndex: number): string { - if (blocks.length === 0) return value + '\n' - if (afterIndex >= blocks.length - 1) { return value + '\n' } diff --git a/packages/common/core/src/features/overlay/createMarkFromOverlay.ts b/packages/common/core/src/features/overlay/createMarkFromOverlay.ts index eaebc043..51afb97f 100644 --- a/packages/common/core/src/features/overlay/createMarkFromOverlay.ts +++ b/packages/common/core/src/features/overlay/createMarkFromOverlay.ts @@ -2,6 +2,8 @@ import type {OverlayMatch} from '../../shared/types' import type {MarkToken} from '../parsing' export function createMarkFromOverlay(match: OverlayMatch, value: string, meta?: string): MarkToken { + const markup = match.option.markup + if (!markup) throw new Error('createMarkFromOverlay: option.markup is required') return { type: 'mark', value, @@ -12,7 +14,7 @@ export function createMarkFromOverlay(match: OverlayMatch, value: string, meta?: end: match.index + match.span.length, }, descriptor: { - markup: match.option.markup!, + markup, index: 0, segments: [], gapTypes: [], diff --git a/packages/common/core/src/features/overlay/filterSuggestions.ts b/packages/common/core/src/features/overlay/filterSuggestions.ts index 08d13974..7283f065 100644 --- a/packages/common/core/src/features/overlay/filterSuggestions.ts +++ b/packages/common/core/src/features/overlay/filterSuggestions.ts @@ -1,4 +1,4 @@ export function filterSuggestions(data: string[], search: string): string[] { const query = search.toLowerCase() - return data.filter(s => s.toLowerCase().indexOf(query) > -1) + return data.filter(s => s.toLowerCase().includes(query)) } \ No newline at end of file diff --git a/packages/common/core/src/shared/utils/resolveOptionSlot.ts b/packages/common/core/src/shared/utils/resolveOptionSlot.ts index cec69d86..aac8e571 100644 --- a/packages/common/core/src/shared/utils/resolveOptionSlot.ts +++ b/packages/common/core/src/shared/utils/resolveOptionSlot.ts @@ -2,5 +2,5 @@ export function resolveOptionSlot(optionConfig: T | ((base: T) if (optionConfig !== undefined) { return typeof optionConfig === 'function' ? optionConfig(baseProps) : optionConfig } - return baseProps ?? {} + return baseProps } \ No newline at end of file diff --git a/packages/react/markput/src/components/BlockContainer.tsx b/packages/react/markput/src/components/BlockContainer.tsx index a77f3803..23657067 100644 --- a/packages/react/markput/src/components/BlockContainer.tsx +++ b/packages/react/markput/src/components/BlockContainer.tsx @@ -9,13 +9,116 @@ import { parseWithParser, type Block, } from '@markput/core' -import type {ElementType} from 'react' -import {memo, useCallback, useMemo, useRef} from 'react' +import type {CSSProperties, ElementType} from 'react' +import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {useStore} from '../lib/providers/StoreContext' -import {DraggableBlock} from './DraggableBlock' +import {DraggableBlock, type MenuPosition} from './DraggableBlock' import {Token} from './Token' +interface BlockMenuProps { + position: MenuPosition + onDelete: () => void + onDuplicate: () => void + onClose: () => void +} + +const BlockMenu = memo(({position, onDelete, onDuplicate, onClose}: BlockMenuProps) => { + const menuRef = useRef(null) + const [hoveredItem, setHoveredItem] = useState(null) + + // Keep a ref so the effect stays stable (empty deps) while always calling + // the latest onClose without re-registering listeners on every render. + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + + useEffect(() => { + const handleMouseDown = (e: globalThis.MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onCloseRef.current() + } + } + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') onCloseRef.current() + } + document.addEventListener('mousedown', handleMouseDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleMouseDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, []) + + const menuStyle: CSSProperties = { + position: 'fixed', + top: position.top, + left: position.left, + background: 'white', + border: '1px solid rgba(55, 53, 47, 0.16)', + borderRadius: 6, + boxShadow: '0 4px 16px rgba(15, 15, 15, 0.12)', + padding: 4, + zIndex: 9999, + minWidth: 160, + fontSize: 14, + } + + const itemStyle = (key: string): CSSProperties => ({ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 10px', + borderRadius: 4, + cursor: 'pointer', + color: key === 'delete' ? '#eb5757' : 'inherit', + background: + hoveredItem === key + ? key === 'delete' + ? 'rgba(235, 87, 87, 0.06)' + : 'rgba(55, 53, 47, 0.06)' + : 'transparent', + userSelect: 'none', + }) + + return ( +
+
setHoveredItem('duplicate')} + onMouseLeave={() => setHoveredItem(null)} + onMouseDown={e => { + e.preventDefault() + onDuplicate() + onClose() + }} + > + + Duplicate +
+
setHoveredItem('delete')} + onMouseLeave={() => setHoveredItem(null)} + onMouseDown={e => { + e.preventDefault() + onDelete() + onClose() + }} + > + 🗑 + Delete +
+
+ ) +}) + +BlockMenu.displayName = 'BlockMenu' + +interface MenuState { + index: number + position: MenuPosition +} + export const BlockContainer = memo(() => { const store = useStore() const tokens = store.state.tokens.use() @@ -29,6 +132,8 @@ export const BlockContainer = memo(() => { const key = store.key const refs = store.refs + const [menuState, setMenuState] = useState(null) + const ContainerComponent = useMemo(() => resolveSlot('container', slots), [slots]) const containerProps = useMemo(() => resolveSlotProps('container', slotProps), [slotProps]) @@ -80,29 +185,50 @@ export const BlockContainer = memo(() => { [value, onChange, applyNewValue] ) + const handleRequestMenu = useCallback((index: number, rect: DOMRect) => { + setMenuState({index, position: {top: rect.bottom + 4, left: rect.left}}) + }, []) + + const closeMenu = useCallback(() => setMenuState(null), []) + return ( - (refs.container = el)} - {...containerProps} - className={className} - style={style} - > - {blocks.map((block, index) => ( - - {block.tokens.map(token => ( - - ))} - - ))} - + <> + (refs.container = el)} + {...containerProps} + className={className} + style={style} + > + {blocks.map((block, index) => ( + + {block.tokens.map(token => ( + + ))} + + ))} + + {menuState && ( + { + handleDelete(menuState.index) + closeMenu() + }} + onDuplicate={() => { + handleDuplicate(menuState.index) + closeMenu() + }} + onClose={closeMenu} + /> + )} + ) }) diff --git a/packages/react/markput/src/components/DraggableBlock.tsx b/packages/react/markput/src/components/DraggableBlock.tsx index 95e3fe52..3b6812d6 100644 --- a/packages/react/markput/src/components/DraggableBlock.tsx +++ b/packages/react/markput/src/components/DraggableBlock.tsx @@ -1,5 +1,10 @@ import type {ReactNode, DragEvent, CSSProperties, MouseEvent} from 'react' -import {Children, memo, useCallback, useRef, useState, useEffect} from 'react' +import {Children, memo, useCallback, useRef, useState} from 'react' + +export interface MenuPosition { + top: number + left: number +} interface DraggableBlockProps { blockIndex: number @@ -7,8 +12,7 @@ interface DraggableBlockProps { readOnly: boolean onReorder: (sourceIndex: number, targetIndex: number) => void onAdd?: (afterIndex: number) => void - onDelete?: (index: number) => void - onDuplicate?: (index: number) => void + onRequestMenu?: (index: number, rect: DOMRect) => void } const GripIcon = memo(() => ( @@ -26,110 +30,11 @@ GripIcon.displayName = 'GripIcon' type DropPosition = 'before' | 'after' | null -interface MenuPosition { - top: number - left: number -} - -interface BlockMenuProps { - position: MenuPosition - onDelete: () => void - onDuplicate: () => void - onClose: () => void -} - -const BlockMenu = memo(({position, onDelete, onDuplicate, onClose}: BlockMenuProps) => { - const menuRef = useRef(null) - const [hoveredItem, setHoveredItem] = useState(null) - - useEffect(() => { - const handleMouseDown = (e: globalThis.MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose() - } - } - const handleKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - document.addEventListener('mousedown', handleMouseDown) - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('mousedown', handleMouseDown) - document.removeEventListener('keydown', handleKeyDown) - } - }, [onClose]) - - const menuStyle: CSSProperties = { - position: 'fixed', - top: position.top, - left: position.left, - background: 'white', - border: '1px solid rgba(55, 53, 47, 0.16)', - borderRadius: 6, - boxShadow: '0 4px 16px rgba(15, 15, 15, 0.12)', - padding: 4, - zIndex: 9999, - minWidth: 160, - fontSize: 14, - } - - const itemStyle = (key: string): CSSProperties => ({ - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '6px 10px', - borderRadius: 4, - cursor: 'pointer', - color: key === 'delete' ? '#eb5757' : 'inherit', - background: - hoveredItem === key - ? key === 'delete' - ? 'rgba(235, 87, 87, 0.06)' - : 'rgba(55, 53, 47, 0.06)' - : 'transparent', - userSelect: 'none', - }) - - return ( -
-
setHoveredItem('duplicate')} - onMouseLeave={() => setHoveredItem(null)} - onMouseDown={e => { - e.preventDefault() - onDuplicate() - onClose() - }} - > - - Duplicate -
-
setHoveredItem('delete')} - onMouseLeave={() => setHoveredItem(null)} - onMouseDown={e => { - e.preventDefault() - onDelete() - onClose() - }} - > - 🗑 - Delete -
-
- ) -}) - -BlockMenu.displayName = 'BlockMenu' - export const DraggableBlock = memo( - ({blockIndex, children, readOnly, onReorder, onAdd, onDelete, onDuplicate}: DraggableBlockProps) => { + ({blockIndex, children, readOnly, onReorder, onAdd, onRequestMenu}: DraggableBlockProps) => { const [isHovered, setIsHovered] = useState(false) const [isDragging, setIsDragging] = useState(false) const [dropPosition, setDropPosition] = useState(null) - const [menuPosition, setMenuPosition] = useState(null) const blockRef = useRef(null) const gripRef = useRef(null) @@ -183,12 +88,14 @@ export const DraggableBlock = memo( [blockIndex, dropPosition, onReorder] ) - const handleGripClick = useCallback((e: MouseEvent) => { - e.preventDefault() - if (!gripRef.current) return - const rect = gripRef.current.getBoundingClientRect() - setMenuPosition({top: rect.bottom + 4, left: rect.left}) - }, []) + const handleGripClick = useCallback( + (e: MouseEvent) => { + e.preventDefault() + if (!gripRef.current) return + onRequestMenu?.(blockIndex, gripRef.current.getBoundingClientRect()) + }, + [blockIndex, onRequestMenu] + ) const handleAddClick = useCallback( (e: MouseEvent) => { @@ -295,15 +202,6 @@ export const DraggableBlock = memo( {Children.count(children) === 0 ?
: children} {dropPosition === 'after' &&
} - - {menuPosition && onDelete && onDuplicate && ( - onDelete(blockIndex)} - onDuplicate={() => onDuplicate(blockIndex)} - onClose={() => setMenuPosition(null)} - /> - )}
) } diff --git a/packages/react/markput/src/components/Suggestions/Suggestions.tsx b/packages/react/markput/src/components/Suggestions/Suggestions.tsx index 857ce5ef..64d0b472 100644 --- a/packages/react/markput/src/components/Suggestions/Suggestions.tsx +++ b/packages/react/markput/src/components/Suggestions/Suggestions.tsx @@ -1,6 +1,6 @@ import {filterSuggestions, navigateSuggestions} from '@markput/core' import type {RefObject} from 'react' -import {useEffect, useMemo, useState} from 'react' +import {useEffect, useMemo, useRef, useState} from 'react' import {useOverlay} from '../../lib/hooks/useOverlay' import {useStore} from '../../lib/providers/StoreContext' @@ -15,29 +15,37 @@ export const Suggestions = () => { const filtered = useMemo(() => filterSuggestions(data, match.value), [match.value, data]) const length = filtered.length + // Refs let the handler always read the latest values without re-registering + // the listener on every keypress (which happened when `active` was a dep). + const activeRef = useRef(active) + activeRef.current = active + const filteredRef = useRef(filtered) + filteredRef.current = filtered + useEffect(() => { const container = store.refs.container if (!container) return const handler = (event: KeyboardEvent) => { - const result = navigateSuggestions(event.key, active, length) + const result = navigateSuggestions(event.key, activeRef.current, length) switch (result.action) { case 'up': case 'down': event.preventDefault() setActive(result.index) break - case 'select': + case 'select': { event.preventDefault() - const suggestion = filtered[result.index] + const suggestion = filteredRef.current[result.index] select({value: suggestion, meta: result.index.toString()}) break + } } } container.addEventListener('keydown', handler) return () => container.removeEventListener('keydown', handler) - }, [length, filtered, active]) + }, [length, select]) if (!filtered.length) return null diff --git a/packages/vue/markput/src/components/DraggableBlock.vue b/packages/vue/markput/src/components/DraggableBlock.vue index 2fc12a92..837a9f10 100644 --- a/packages/vue/markput/src/components/DraggableBlock.vue +++ b/packages/vue/markput/src/components/DraggableBlock.vue @@ -1,5 +1,5 @@ From 267ae4983ec4802397aa9ab65cac2b2ea7325bb4 Mon Sep 17 00:00:00 2001 From: Nowely Date: Mon, 9 Mar 2026 02:09:40 +0300 Subject: [PATCH 17/25] test(blocks): enhance Block component tests for empty block handling - Added a new utility function, getEditableInBlock, to simplify test interactions with editable blocks. - Updated tests to verify behavior when adding blocks below empty blocks, ensuring correct block count and content rendering. - Improved assertions to reflect expected outcomes when manipulating block structures in the editor. --- .../storybook/src/pages/Block/Block.spec.tsx | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx index 809dfc68..6d5fdb87 100644 --- a/packages/react/storybook/src/pages/Block/Block.spec.tsx +++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx @@ -18,6 +18,10 @@ function getBlockDiv(grip: HTMLElement) { return grip.closest('[data-testid="block"]') as HTMLElement } +function getEditableInBlock(blockDiv: HTMLElement) { + return blockDiv.querySelector('[contenteditable="true"]') as HTMLElement +} + /** Read the raw value from the
 rendered by the Text component */
 function getRawValue(container: Element) {
 	return container.querySelector('pre')!.textContent!
@@ -122,15 +126,15 @@ describe('Block Feature', () => {
 			await openMenuForGrip(container, 0)
 			await userEvent.click(page.getByText('Delete').element())
 
-			// Now value is '' — try to add a block
-			// Bug #1: if (!value || !onChange) return — empty string is falsy, so this no-ops
-			const gripsAfterEmpty = getGrips(container)
-			if (gripsAfterEmpty.length > 0) {
-				await openMenuForGrip(container, 0)
-				await userEvent.click(page.getByText('Add below').element())
-				// If bug is fixed, block count should increase
-				expect(getGrips(container).length).toBeGreaterThan(0)
-			}
+			// Now value is '' — editor still renders 1 empty block
+			// Bug #1: if (!value || !onChange) return — empty string is falsy, so addBlock no-ops
+			expect(getGrips(container)).toHaveLength(1)
+
+			await openMenuForGrip(container, 0)
+			await userEvent.click(page.getByText('Add below').element())
+
+			// Bug #1 would keep count at 1; fixed version should increase to 2
+			expect(getGrips(container)).toHaveLength(2)
 		})
 	})
 
@@ -187,15 +191,14 @@ describe('Block Feature', () => {
 
 		it('new block added below first block is empty', async () => {
 			const {container} = await render()
-			const originalValue = getRawValue(container)
 
 			await openMenuForGrip(container, 0)
 			await userEvent.click(page.getByText('Add below').element())
 
 			const raw = getRawValue(container)
-			// Original "First block of plain text\n\n..."
-			// After add: "First block of plain text\n\n\n\n..." (new separator + empty block before second block)
-			expect(raw.length).toBeGreaterThan(originalValue.length)
+			// An empty block is inserted between block 0 and block 1:
+			// "First block of plain text\n\n\n\nSecond block of plain text"
+			expect(raw).toContain('First block of plain text\n\n\n\nSecond block of plain text')
 		})
 	})
 
@@ -270,8 +273,8 @@ describe('Block Feature', () => {
 			const {container} = await render()
 			expect(getGrips(container)).toHaveLength(5)
 
-			const blockDiv = getBlockDiv(getGrips(container)[0])
-			await focusAtEnd(blockDiv)
+			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+			await focusAtEnd(editable)
 			await userEvent.keyboard('{Enter}')
 
 			expect(getGrips(container)).toHaveLength(6)
@@ -281,8 +284,8 @@ describe('Block Feature', () => {
 			const {container} = await render()
 			const originalValue = getRawValue(container)
 
-			const blockDiv = getBlockDiv(getGrips(container)[0])
-			await focusAtEnd(blockDiv)
+			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+			await focusAtEnd(editable)
 			await userEvent.keyboard('{Enter}')
 
 			const newValue = getRawValue(container)
@@ -295,8 +298,8 @@ describe('Block Feature', () => {
 		it('pressing Shift+Enter does NOT create a new block', async () => {
 			const {container} = await render()
 
-			const blockDiv = getBlockDiv(getGrips(container)[0])
-			await focusAtEnd(blockDiv)
+			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+			await focusAtEnd(editable)
 			await userEvent.keyboard('{Shift>}{Enter}{/Shift}')
 
 			expect(getGrips(container)).toHaveLength(5)

From cab208c1f64592a69e0fe36f09810483683b6b4d Mon Sep 17 00:00:00 2001
From: Nowely 
Date: Mon, 9 Mar 2026 02:15:54 +0300
Subject: [PATCH 18/25] test(blocks): refactor and enhance block component
 tests

- Updated test descriptions for clarity and consistency, aligning with best practices.
- Consolidated test cases for rendering blocks in various components, ensuring accurate block count assertions.
- Improved readability of test structure by organizing related tests under descriptive sections.
---
 .../storybook/src/pages/Block/Block.spec.tsx  | 294 ++++++++----------
 1 file changed, 123 insertions(+), 171 deletions(-)

diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx
index 6d5fdb87..efe76178 100644
--- a/packages/react/storybook/src/pages/Block/Block.spec.tsx
+++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx
@@ -34,114 +34,36 @@ async function openMenuForGrip(container: Element, gripIndex: number) {
 	await userEvent.click(grip)
 }
 
-describe('Block Feature', () => {
-	// ── Phase 1: Bug-exposing tests ────────────────────────────────
-
-	describe('Rendering — block counts', () => {
-		it('BasicDraggable renders 5 blocks', async () => {
-			const {container} = await render()
-			expect(getGrips(container)).toHaveLength(5)
-		})
-
-		it('MarkdownDocument renders 6 blocks', async () => {
-			const {container} = await render()
-			// Mark tokens can merge adjacent text blocks, resulting in 6 blocks
-			expect(getGrips(container)).toHaveLength(6)
-		})
-
-		it('PlainTextBlocks renders 5 blocks', async () => {
-			const {container} = await render()
-			expect(getGrips(container)).toHaveLength(5)
-		})
-
-		it('ReadOnlyDraggable renders no grip buttons', async () => {
-			const {container} = await render()
-			expect(getGrips(container)).toHaveLength(0)
-		})
-
-		it('ReadOnlyDraggable still renders content', async () => {
-			await render()
-			await expect.element(page.getByText(/Read-Only/).first()).toBeInTheDocument()
-			await expect.element(page.getByText(/Section A/).first()).toBeInTheDocument()
-			await expect.element(page.getByText(/Section B/).first()).toBeInTheDocument()
-		})
+describe('Feature: blocks', () => {
+	it('should render 5 blocks for BasicDraggable', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(5)
 	})
 
-	describe('Bug #5 — Add block on last block creates trailing empty block', () => {
-		it('adding below last block increases count by exactly 1', async () => {
-			const {container} = await render()
-			expect(getGrips(container)).toHaveLength(5)
-
-			// Open menu on last block (index 4)
-			await openMenuForGrip(container, 4)
-			await userEvent.click(page.getByText('Add below').element())
-
-			expect(getGrips(container)).toHaveLength(6)
-		})
-
-		it('value after adding below last block does not end with double separator', async () => {
-			const {container} = await render()
-			await openMenuForGrip(container, 4)
-			await userEvent.click(page.getByText('Add below').element())
-
-			const raw = getRawValue(container)
-			// Should end with exactly one \n\n (the separator before the new empty block),
-			// NOT \n\n\n\n (which would mean a trailing extra separator)
-			expect(raw.endsWith('\n\n\n\n')).toBe(false)
-		})
+	it('should render 6 blocks for MarkdownDocument', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(6)
 	})
 
-	describe('Bug #6 — Delete single remaining block', () => {
-		it('deleting blocks until one remains, then deleting that block', async () => {
-			const {container} = await render()
-
-			// Delete blocks from the end until only 1 remains
-			for (let i = 4; i > 0; i--) {
-				await openMenuForGrip(container, i)
-				await userEvent.click(page.getByText('Delete').element())
-			}
-
-			expect(getGrips(container)).toHaveLength(1)
-
-			// Delete the last remaining block — exposes bug #6
-			await openMenuForGrip(container, 0)
-			await userEvent.click(page.getByText('Delete').element())
-
-			const raw = getRawValue(container)
-			// After deleting the only block, value becomes '' — the editor should still render
-			expect(raw).toBe('')
-		})
+	it('should render 5 blocks for PlainTextBlocks', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(5)
 	})
 
-	describe('Bug #1 — Empty value guard blocks operations', () => {
-		it('adding a block after deleting all blocks should work', async () => {
-			const {container} = await render()
-
-			// Delete all blocks
-			for (let i = 4; i > 0; i--) {
-				await openMenuForGrip(container, i)
-				await userEvent.click(page.getByText('Delete').element())
-			}
-			// Delete the last one
-			await openMenuForGrip(container, 0)
-			await userEvent.click(page.getByText('Delete').element())
-
-			// Now value is '' — editor still renders 1 empty block
-			// Bug #1: if (!value || !onChange) return — empty string is falsy, so addBlock no-ops
-			expect(getGrips(container)).toHaveLength(1)
-
-			await openMenuForGrip(container, 0)
-			await userEvent.click(page.getByText('Add below').element())
-
-			// Bug #1 would keep count at 1; fixed version should increase to 2
-			expect(getGrips(container)).toHaveLength(2)
-		})
+	it('should render no grip buttons in read-only mode', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(0)
 	})
 
-	// ── Phase 2: Block Menu ────────────────────────────────────────
+	it('should render content in read-only mode', async () => {
+		await render()
+		await expect.element(page.getByText(/Read-Only/).first()).toBeInTheDocument()
+		await expect.element(page.getByText(/Section A/).first()).toBeInTheDocument()
+		await expect.element(page.getByText(/Section B/).first()).toBeInTheDocument()
+	})
 
-	describe('Block Menu', () => {
-		it('clicking grip opens menu with Add below, Duplicate, Delete', async () => {
+	describe('menu', () => {
+		it('should open with Add below, Duplicate, Delete options', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 0)
 
@@ -150,7 +72,7 @@ describe('Block Feature', () => {
 			await expect.element(page.getByText('Delete')).toBeInTheDocument()
 		})
 
-		it('Escape key closes the menu', async () => {
+		it('should close on Escape', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 0)
 			await expect.element(page.getByText('Add below')).toBeInTheDocument()
@@ -159,21 +81,18 @@ describe('Block Feature', () => {
 			await expect.element(page.getByText('Add below')).not.toBeInTheDocument()
 		})
 
-		it('clicking outside the menu closes it', async () => {
+		it('should close when clicking outside', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 0)
 			await expect.element(page.getByText('Add below')).toBeInTheDocument()
 
-			// Click on the story wrapper (outside the menu)
 			await userEvent.click(container.firstElementChild!)
 			await expect.element(page.getByText('Add below')).not.toBeInTheDocument()
 		})
 	})
 
-	// ── Phase 2: Add Block ─────────────────────────────────────────
-
-	describe('Add Block', () => {
-		it('add below first block increases block count by 1', async () => {
+	describe('add block', () => {
+		it('should increase block count by 1 when adding below first block', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 0)
 			await userEvent.click(page.getByText('Add below').element())
@@ -181,7 +100,7 @@ describe('Block Feature', () => {
 			expect(getGrips(container)).toHaveLength(6)
 		})
 
-		it('add below middle block increases block count by 1', async () => {
+		it('should increase block count by 1 when adding below middle block', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 2)
 			await userEvent.click(page.getByText('Add below').element())
@@ -189,23 +108,57 @@ describe('Block Feature', () => {
 			expect(getGrips(container)).toHaveLength(6)
 		})
 
-		it('new block added below first block is empty', async () => {
+		it('should increase block count by 1 when adding below last block', async () => {
 			const {container} = await render()
+			await openMenuForGrip(container, 4)
+			await userEvent.click(page.getByText('Add below').element())
+
+			expect(getGrips(container)).toHaveLength(6)
+		})
 
+		it('should insert an empty block between the target and next block', async () => {
+			const {container} = await render()
 			await openMenuForGrip(container, 0)
 			await userEvent.click(page.getByText('Add below').element())
 
 			const raw = getRawValue(container)
-			// An empty block is inserted between block 0 and block 1:
-			// "First block of plain text\n\n\n\nSecond block of plain text"
 			expect(raw).toContain('First block of plain text\n\n\n\nSecond block of plain text')
 		})
-	})
 
-	// ── Phase 2: Delete Block ──────────────────────────────────────
+		it('should not create a trailing separator when adding below last block', async () => {
+			const {container} = await render()
+			await openMenuForGrip(container, 4)
+			await userEvent.click(page.getByText('Add below').element())
 
-	describe('Delete Block', () => {
-		it('delete middle block decreases count by 1', async () => {
+			const raw = getRawValue(container)
+			expect(raw.endsWith('\n\n\n\n')).toBe(false)
+		})
+
+		it('should work when value is empty', async () => {
+			const {container} = await render()
+
+			// Delete all blocks until value is '' — sequential DOM interactions
+			// eslint-disable-next-line no-await-in-loop
+			for (let i = 4; i > 0; i--) {
+				await openMenuForGrip(container, i)
+				await userEvent.click(page.getByText('Delete').element())
+			}
+			await openMenuForGrip(container, 0)
+			await userEvent.click(page.getByText('Delete').element())
+
+			// Editor renders 1 empty block even when value is ''
+			// Bug: if (!value || !onChange) return — empty string is falsy, so addBlock no-ops
+			expect(getGrips(container)).toHaveLength(1)
+
+			await openMenuForGrip(container, 0)
+			await userEvent.click(page.getByText('Add below').element())
+
+			expect(getGrips(container)).toHaveLength(2)
+		})
+	})
+
+	describe('delete block', () => {
+		it('should decrease count by 1 when deleting middle block', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 2)
 			await userEvent.click(page.getByText('Delete').element())
@@ -213,32 +166,46 @@ describe('Block Feature', () => {
 			expect(getGrips(container)).toHaveLength(4)
 		})
 
-		it('delete first block preserves remaining content', async () => {
+		it('should preserve remaining content when deleting first block', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 0)
 			await userEvent.click(page.getByText('Delete').element())
 
 			expect(getGrips(container)).toHaveLength(4)
-			const raw = getRawValue(container)
-			expect(raw).toContain('Second block of plain text')
+			expect(getRawValue(container)).toContain('Second block of plain text')
 		})
 
-		it('delete last block decreases count by 1', async () => {
+		it('should decrease count by 1 when deleting last block', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 4)
 			await userEvent.click(page.getByText('Delete').element())
 
 			expect(getGrips(container)).toHaveLength(4)
-			const raw = getRawValue(container)
-			expect(raw).toContain('Fourth block of plain text')
-			expect(raw).not.toContain('Fifth block of plain text')
+			expect(getRawValue(container)).toContain('Fourth block of plain text')
+			expect(getRawValue(container)).not.toContain('Fifth block of plain text')
 		})
-	})
 
-	// ── Phase 2: Duplicate Block ───────────────────────────────────
+		it('should result in empty value when deleting the last remaining block', async () => {
+			const {container} = await render()
 
-	describe('Duplicate Block', () => {
-		it('duplicate first block increases count by 1', async () => {
+			// Sequential DOM interactions — must await each step
+			// eslint-disable-next-line no-await-in-loop
+			for (let i = 4; i > 0; i--) {
+				await openMenuForGrip(container, i)
+				await userEvent.click(page.getByText('Delete').element())
+			}
+
+			expect(getGrips(container)).toHaveLength(1)
+
+			await openMenuForGrip(container, 0)
+			await userEvent.click(page.getByText('Delete').element())
+
+			expect(getRawValue(container)).toBe('')
+		})
+	})
+
+	describe('duplicate block', () => {
+		it('should increase count by 1 when duplicating first block', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 0)
 			await userEvent.click(page.getByText('Duplicate').element())
@@ -246,18 +213,16 @@ describe('Block Feature', () => {
 			expect(getGrips(container)).toHaveLength(6)
 		})
 
-		it('duplicate creates a copy with same text content', async () => {
+		it('should create a copy with the same text content', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 0)
 			await userEvent.click(page.getByText('Duplicate').element())
 
-			const raw = getRawValue(container)
-			// "First block of plain text" should appear twice
-			const matches = raw.match(/First block of plain text/g)
+			const matches = getRawValue(container).match(/First block of plain text/g)
 			expect(matches).toHaveLength(2)
 		})
 
-		it('duplicate last block increases count by 1', async () => {
+		it('should increase count by 1 when duplicating last block', async () => {
 			const {container} = await render()
 			await openMenuForGrip(container, 4)
 			await userEvent.click(page.getByText('Duplicate').element())
@@ -266,10 +231,8 @@ describe('Block Feature', () => {
 		})
 	})
 
-	// ── Phase 3: Enter Key ─────────────────────────────────────────
-
-	describe('Enter Key — new block', () => {
-		it('pressing Enter at end of block creates a new block', async () => {
+	describe('enter key', () => {
+		it('should create a new block when pressing Enter at end of block', async () => {
 			const {container} = await render()
 			expect(getGrips(container)).toHaveLength(5)
 
@@ -280,7 +243,7 @@ describe('Block Feature', () => {
 			expect(getGrips(container)).toHaveLength(6)
 		})
 
-		it('pressing Enter preserves all block content', async () => {
+		it('should preserve all block content after pressing Enter', async () => {
 			const {container} = await render()
 			const originalValue = getRawValue(container)
 
@@ -290,12 +253,11 @@ describe('Block Feature', () => {
 
 			const newValue = getRawValue(container)
 			expect(newValue).not.toBe(originalValue)
-			// Original content should still be present
 			expect(newValue).toContain('First block of plain text')
 			expect(newValue).toContain('Fifth block of plain text')
 		})
 
-		it('pressing Shift+Enter does NOT create a new block', async () => {
+		it('should not create a new block when pressing Shift+Enter', async () => {
 			const {container} = await render()
 
 			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
@@ -306,48 +268,38 @@ describe('Block Feature', () => {
 		})
 	})
 
-	// ── Phase 3: Drag & Drop ───────────────────────────────────────
-
-	describe('Drag & Drop', () => {
-		it.todo('drag block 0 after block 2 reorders blocks')
-		it.todo('drag block onto itself causes no change')
+	describe('drag & drop', () => {
+		it.todo('should reorder blocks when dragging block 0 after block 2')
+		it.todo('should not change order when dragging block onto itself')
 	})
 
-	// ── Phase 4: Regression / compound scenarios ───────────────────
+	it('should restore original value after add then delete', async () => {
+		const {container} = await render()
+		const original = getRawValue(container)
 
-	describe('Regression', () => {
-		it('add then delete restores original value', async () => {
-			const {container} = await render()
-			const original = getRawValue(container)
-
-			// Add below first block
-			await openMenuForGrip(container, 0)
-			await userEvent.click(page.getByText('Add below').element())
-			expect(getGrips(container)).toHaveLength(6)
+		await openMenuForGrip(container, 0)
+		await userEvent.click(page.getByText('Add below').element())
+		expect(getGrips(container)).toHaveLength(6)
 
-			// Delete the newly added empty block (index 1)
-			await openMenuForGrip(container, 1)
-			await userEvent.click(page.getByText('Delete').element())
-			expect(getGrips(container)).toHaveLength(5)
+		await openMenuForGrip(container, 1)
+		await userEvent.click(page.getByText('Delete').element())
+		expect(getGrips(container)).toHaveLength(5)
 
-			expect(getRawValue(container)).toBe(original)
-		})
+		expect(getRawValue(container)).toBe(original)
+	})
 
-		it('duplicate then delete restores original value', async () => {
-			const {container} = await render()
-			const original = getRawValue(container)
+	it('should restore original value after duplicate then delete', async () => {
+		const {container} = await render()
+		const original = getRawValue(container)
 
-			// Duplicate first block
-			await openMenuForGrip(container, 0)
-			await userEvent.click(page.getByText('Duplicate').element())
-			expect(getGrips(container)).toHaveLength(6)
+		await openMenuForGrip(container, 0)
+		await userEvent.click(page.getByText('Duplicate').element())
+		expect(getGrips(container)).toHaveLength(6)
 
-			// Delete the duplicate (index 1)
-			await openMenuForGrip(container, 1)
-			await userEvent.click(page.getByText('Delete').element())
-			expect(getGrips(container)).toHaveLength(5)
+		await openMenuForGrip(container, 1)
+		await userEvent.click(page.getByText('Delete').element())
+		expect(getGrips(container)).toHaveLength(5)
 
-			expect(getRawValue(container)).toBe(original)
-		})
+		expect(getRawValue(container)).toBe(original)
 	})
 })
\ No newline at end of file

From a99d708dada57b82cd4687a12c4573049aa4851c Mon Sep 17 00:00:00 2001
From: Nowely 
Date: Mon, 9 Mar 2026 02:29:31 +0300
Subject: [PATCH 19/25] feat(blocks): implement backspace functionality for
 empty blocks and enhance block operations

- Added functionality in KeyDownController to delete an empty block when Backspace is pressed, improving user experience.
- Updated BlockContainer to handle null checks for value and onChange, ensuring robust block operations.
- Enhanced tests to verify the behavior of block deletion and focus management after adding new blocks, ensuring accurate block count and content rendering.
---
 .../src/features/input/KeyDownController.ts   |  38 ++++++
 .../markput/src/components/BlockContainer.tsx |  15 ++-
 .../storybook/src/pages/Block/Block.spec.tsx  | 112 +++++++++++++++++-
 3 files changed, 158 insertions(+), 7 deletions(-)

diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts
index a0d1a5bf..1724669f 100644
--- a/packages/common/core/src/features/input/KeyDownController.ts
+++ b/packages/common/core/src/features/input/KeyDownController.ts
@@ -1,5 +1,6 @@
 import type {NodeProxy} from '../../shared/classes/NodeProxy'
 import {KEYBOARD} from '../../shared/constants'
+import {deleteBlock} from '../blocks/blockOperations'
 import {BLOCK_SEPARATOR} from '../blocks/config'
 import {splitTokensIntoBlocks} from '../blocks/splitTokensIntoBlocks'
 import {Caret} from '../caret'
@@ -87,6 +88,43 @@ export class KeyDownController {
 				}
 			}
 		}
+
+		// Notion-like: Backspace on an empty block deletes the block
+		if (event.key === KEYBOARD.BACKSPACE && this.store.state.block.get()) {
+			const container = this.store.refs.container
+			if (!container) return
+
+			const blockDivs = Array.from(container.children)
+			const blockIndex = blockDivs.findIndex(
+				div => div === document.activeElement || div.contains(document.activeElement as Node)
+			)
+			if (blockIndex === -1) return
+
+			const tokens = this.store.state.tokens.get()
+			const blocks = splitTokensIntoBlocks(tokens)
+			if (blockIndex >= blocks.length) return
+
+			const block = blocks[blockIndex]
+			const blockText = block.tokens.map(t => ('content' in t ? (t as {content: string}).content : '')).join('')
+			if (blockText !== '') return
+
+			event.preventDefault()
+			const value = this.store.state.value.get() ?? this.store.state.previousValue.get() ?? ''
+			if (!this.store.state.onChange.get()) return
+
+			const newValue = deleteBlock(value, blocks, blockIndex)
+			this.store.applyValue(newValue)
+
+			queueMicrotask(() => {
+				const newDivs = container.children
+				const targetIndex = Math.max(0, blockIndex - 1)
+				const target = newDivs[targetIndex] as HTMLElement | undefined
+				if (target) {
+					target.focus()
+					Caret.trySetIndex(target, Infinity)
+				}
+			})
+		}
 	}
 
 	#handleEnter(event: KeyboardEvent) {
diff --git a/packages/react/markput/src/components/BlockContainer.tsx b/packages/react/markput/src/components/BlockContainer.tsx
index f8ee907c..ac804e96 100644
--- a/packages/react/markput/src/components/BlockContainer.tsx
+++ b/packages/react/markput/src/components/BlockContainer.tsx
@@ -171,7 +171,7 @@ export const BlockContainer = memo(() => {
 
 	const handleReorder = useCallback(
 		(sourceIndex: number, targetIndex: number) => {
-			if (!value || !onChange) return
+			if (value == null || !onChange) return
 			const newValue = reorderBlocks(value, blocksRef.current, sourceIndex, targetIndex)
 			if (newValue !== value) store.applyValue(newValue)
 		},
@@ -180,15 +180,22 @@ export const BlockContainer = memo(() => {
 
 	const handleAdd = useCallback(
 		(afterIndex: number) => {
-			if (!value || !onChange) return
+			if (value == null || !onChange) return
 			store.applyValue(addBlock(value, blocksRef.current, afterIndex))
+			queueMicrotask(() => {
+				const container = store.refs.container
+				if (!container) return
+				const newBlockIndex = afterIndex + 1
+				const target = container.children[newBlockIndex] as HTMLElement | undefined
+				target?.focus()
+			})
 		},
 		[store, value, onChange]
 	)
 
 	const handleDelete = useCallback(
 		(index: number) => {
-			if (!value || !onChange) return
+			if (value == null || !onChange) return
 			store.applyValue(deleteBlock(value, blocksRef.current, index))
 		},
 		[store, value, onChange]
@@ -196,7 +203,7 @@ export const BlockContainer = memo(() => {
 
 	const handleDuplicate = useCallback(
 		(index: number) => {
-			if (!value || !onChange) return
+			if (value == null || !onChange) return
 			store.applyValue(duplicateBlock(value, blocksRef.current, index))
 		},
 		[store, value, onChange]
diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx
index efe76178..53615690 100644
--- a/packages/react/storybook/src/pages/Block/Block.spec.tsx
+++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx
@@ -27,6 +27,49 @@ function getRawValue(container: Element) {
 	return container.querySelector('pre')!.textContent!
 }
 
+/**
+ * Simulate an HTML5 drag-and-drop: drag the grip at sourceGripIndex and drop it
+ * onto the block at targetBlockIndex. The drop lands in the 'after' position
+ * (cursor past the midpoint of the target) by default.
+ */
+async function simulateDragBlock(
+	container: Element,
+	sourceGripIndex: number,
+	targetBlockIndex: number,
+	position: 'before' | 'after' = 'after'
+) {
+	const grips = getGrips(container)
+	const blocks = Array.from(container.querySelectorAll('[data-testid="block"]')) as HTMLElement[]
+	const grip = grips[sourceGripIndex]
+	const targetBlock = blocks[targetBlockIndex]
+
+	const dt = new DataTransfer()
+
+	// dragstart — React handler sets dt data as side-effect
+	grip.dispatchEvent(new DragEvent('dragstart', {bubbles: true, cancelable: true, dataTransfer: dt}))
+
+	// dragover — React handler reads clientY to set dropPosition state
+	const rect = targetBlock.getBoundingClientRect()
+	targetBlock.dispatchEvent(
+		new DragEvent('dragover', {
+			bubbles: true,
+			cancelable: true,
+			dataTransfer: dt,
+			clientY: position === 'before' ? rect.top + 1 : rect.bottom - 1,
+		})
+	)
+
+	// Allow React to flush the dropPosition state update before drop fires
+	await new Promise(r => setTimeout(r, 50))
+
+	// drop — React handler reads dt data and calls onReorder
+	targetBlock.dispatchEvent(new DragEvent('drop', {bubbles: true, cancelable: true, dataTransfer: dt}))
+	grip.dispatchEvent(new DragEvent('dragend', {bubbles: true, cancelable: true}))
+
+	// Allow React to re-render after reorder
+	await new Promise(r => setTimeout(r, 50))
+}
+
 /** Hover a block to reveal its grip, then click it to open the menu */
 async function openMenuForGrip(container: Element, gripIndex: number) {
 	const grip = getGrips(container)[gripIndex]
@@ -147,7 +190,6 @@ describe('Feature: blocks', () => {
 			await userEvent.click(page.getByText('Delete').element())
 
 			// Editor renders 1 empty block even when value is ''
-			// Bug: if (!value || !onChange) return — empty string is falsy, so addBlock no-ops
 			expect(getGrips(container)).toHaveLength(1)
 
 			await openMenuForGrip(container, 0)
@@ -269,8 +311,72 @@ describe('Feature: blocks', () => {
 	})
 
 	describe('drag & drop', () => {
-		it.todo('should reorder blocks when dragging block 0 after block 2')
-		it.todo('should not change order when dragging block onto itself')
+		it('should reorder blocks when dragging block 0 after block 2', async () => {
+			const {container} = await render()
+
+			await simulateDragBlock(container, 0, 2)
+
+			const raw = getRawValue(container)
+			expect(raw.indexOf('First block of plain text')).toBeGreaterThan(raw.indexOf('Third block of plain text'))
+		})
+
+		it('should not change order when dragging block onto itself', async () => {
+			const {container} = await render()
+			const original = getRawValue(container)
+
+			await simulateDragBlock(container, 1, 1)
+
+			expect(getRawValue(container)).toBe(original)
+		})
+	})
+
+	describe('backspace on empty block', () => {
+		it('should delete the block and reduce count by 1', async () => {
+			const {container} = await render()
+
+			// Insert an empty block after block 0
+			await openMenuForGrip(container, 0)
+			await userEvent.click(page.getByText('Add below').element())
+			expect(getGrips(container)).toHaveLength(6)
+
+			// Focus the new empty block (index 1) and press Backspace
+			const newBlockDiv = getBlockDiv(getGrips(container)[1])
+			newBlockDiv.focus()
+			await userEvent.keyboard('{Backspace}')
+
+			expect(getGrips(container)).toHaveLength(5)
+		})
+
+		it('should not delete a non-empty block on Backspace', async () => {
+			const {container} = await render()
+			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+			await focusAtEnd(editable)
+			await userEvent.keyboard('{Backspace}')
+
+			// Only one character was deleted, not the whole block
+			expect(getGrips(container)).toHaveLength(5)
+		})
+	})
+
+	it('should focus the new empty block after Add below', async () => {
+		const {container} = await render()
+		await openMenuForGrip(container, 0)
+		await userEvent.click(page.getByText('Add below').element())
+
+		const newBlockDiv = getBlockDiv(getGrips(container)[1])
+		expect(document.activeElement).toBe(newBlockDiv)
+	})
+
+	it('should split block at caret when pressing Enter at the beginning', async () => {
+		const {container} = await render()
+		const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+		await userEvent.click(editable)
+		await userEvent.keyboard('{Home}')
+		await userEvent.keyboard('{Enter}')
+
+		expect(getGrips(container)).toHaveLength(6)
+		// Original first-block text should still be present
+		expect(getRawValue(container)).toContain('First block of plain text')
 	})
 
 	it('should restore original value after add then delete', async () => {

From 21d4b4ccc78f8b7b6afff4fcc0cfe77d1c07c1ef Mon Sep 17 00:00:00 2001
From: Nowely 
Date: Mon, 9 Mar 2026 17:20:58 +0300
Subject: [PATCH 20/25] fix(blocks): implement Notion-like keyboard navigation
 for block editor

- Fix Caret.setIndex to use TreeWalker for accurate text node positioning,
  handling non-text firstChild and Infinity offset (fixes IndexSizeError)
- Add Caret.getCaretRect, isCaretOnFirstLine/LastLine, setAtX for
  ArrowUp/Down cross-block navigation with x-position preservation
- Add mergeBlocks() to blockOperations for Backspace/Delete block merging
- Fix navigation/index.ts shiftFocusPrev/Next to correctly skip non-editable
  marks and cross block boundaries
- Add #handleArrowUpDown in KeyDownController for cross-block Up/Down
- Fix #handleEnter to use raw value offset (via getCaretRawPosInBlock) instead
  of visual caret offset, fixing Enter with marks present
- Add Backspace merge: non-empty block at pos 0 merges with previous block
- Add Delete merge: caret at end of non-last block merges with next block
- Fix #handleDelete to skip mark/span logic in block mode (focus target is a
  block div, not a span/mark), preventing wrongful deleteMark calls on
  odd-indexed blocks

Co-Authored-By: Claude Sonnet 4.6 
---
 .../src/features/blocks/blockOperations.ts    |  11 +
 .../common/core/src/features/blocks/index.ts  |   2 +-
 .../core/src/features/caret/Caret.spec.ts     |  75 ++++--
 .../common/core/src/features/caret/Caret.ts   | 103 ++++++++-
 .../src/features/input/KeyDownController.ts   | 213 ++++++++++++++----
 .../core/src/features/navigation/index.ts     |  33 ++-
 6 files changed, 355 insertions(+), 82 deletions(-)

diff --git a/packages/common/core/src/features/blocks/blockOperations.ts b/packages/common/core/src/features/blocks/blockOperations.ts
index a070792d..70ff99cf 100644
--- a/packages/common/core/src/features/blocks/blockOperations.ts
+++ b/packages/common/core/src/features/blocks/blockOperations.ts
@@ -20,6 +20,17 @@ export function deleteBlock(value: string, blocks: Block[], index: number): stri
 	return value.slice(0, blocks[index].startPos) + value.slice(blocks[index + 1].startPos)
 }
 
+/**
+ * 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`.
+ */
+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)
+}
+
 export function duplicateBlock(value: string, blocks: Block[], index: number): string {
 	const block = blocks[index]
 	const blockText = value.substring(block.startPos, block.endPos)
diff --git a/packages/common/core/src/features/blocks/index.ts b/packages/common/core/src/features/blocks/index.ts
index 4a298602..9e5e1503 100644
--- a/packages/common/core/src/features/blocks/index.ts
+++ b/packages/common/core/src/features/blocks/index.ts
@@ -1,4 +1,4 @@
 export {splitTokensIntoBlocks, type Block} from './splitTokensIntoBlocks'
 export {reorderBlocks} from './reorderBlocks'
-export {addBlock, deleteBlock, duplicateBlock} from './blockOperations'
+export {addBlock, deleteBlock, duplicateBlock, mergeBlocks} from './blockOperations'
 export {BLOCK_SEPARATOR, getAlwaysShowHandle} from './config'
\ No newline at end of file
diff --git a/packages/common/core/src/features/caret/Caret.spec.ts b/packages/common/core/src/features/caret/Caret.spec.ts
index 0eefb58b..ab7eb390 100644
--- a/packages/common/core/src/features/caret/Caret.spec.ts
+++ b/packages/common/core/src/features/caret/Caret.spec.ts
@@ -7,12 +7,25 @@ const mockGetSelection = vi.fn()
 const mockGetBoundingClientRect = vi.fn()
 const mockGetRangeAt = vi.fn()
 
+const mockAddRange = vi.fn()
+const mockRemoveAllRanges = vi.fn()
+const mockSetStart = vi.fn()
+const mockCollapse = vi.fn()
+const mockCreatedRange = {
+	setStart: mockSetStart,
+	collapse: mockCollapse,
+}
+const mockCreateRange = vi.fn(() => mockCreatedRange)
+const mockCreateTreeWalker = vi.fn()
+
 const mockSelection = {
 	isCollapsed: true,
 	anchorOffset: 5,
 	anchorNode: {textContent: 'Hello world'} as {textContent: string} | null,
 	getRangeAt: mockGetRangeAt,
 	setPosition: vi.fn(),
+	removeAllRanges: mockRemoveAllRanges,
+	addRange: mockAddRange,
 	rangeCount: 1,
 }
 
@@ -38,11 +51,17 @@ Object.defineProperty(global, 'window', {
 
 Object.defineProperty(global, 'document', {
 	value: {
-		createElement: vi.fn(tag => ({
-			tagName: tag.toUpperCase(),
-			textContent: '',
-			firstChild: {nodeType: 3, textContent: ''},
-		})),
+		createElement: vi.fn(tag => {
+			const textNode = {nodeType: 3, textContent: '', length: 0}
+			return {
+				tagName: tag.toUpperCase(),
+				textContent: '',
+				firstChild: textNode,
+				_textNode: textNode,
+			}
+		}),
+		createRange: mockCreateRange,
+		createTreeWalker: mockCreateTreeWalker,
 	},
 	writable: true,
 })
@@ -197,19 +216,28 @@ describe(`Utility: ${Caret.name}`, () => {
 	})
 
 	describe('setIndex', () => {
-		it('should set caret position in element', () => {
+		it('should set caret position in element via TreeWalker', () => {
 			const element = document.createElement('div')
-			element.textContent = 'Hello world'
-
-			mockSelection.rangeCount = 1
-			mockSelection.anchorNode = {textContent: 'test'}
+			const textNode = {nodeType: 3, textContent: 'Hello world', length: 11}
+			let visited = false
+			mockCreateTreeWalker.mockReturnValue({
+				nextNode: () => {
+					if (!visited) {
+						visited = true
+						return textNode
+					}
+					return null
+				},
+			})
 			mockGetSelection.mockReturnValue(mockSelection)
-			mockGetRangeAt.mockReturnValue(mockRange)
 
 			Caret.setIndex(element, 5)
 
-			expect(mockRange.setStart).toHaveBeenCalledWith(element.firstChild, 5)
-			expect(mockRange.setEnd).toHaveBeenCalledWith(element.firstChild, 5)
+			expect(mockCreateTreeWalker).toHaveBeenCalledWith(element, 4)
+			expect(mockSetStart).toHaveBeenCalledWith(textNode, 5)
+			expect(mockCollapse).toHaveBeenCalledWith(true)
+			expect(mockRemoveAllRanges).toHaveBeenCalled()
+			expect(mockAddRange).toHaveBeenCalledWith(mockCreatedRange)
 		})
 
 		it('should do nothing when no selection', () => {
@@ -219,20 +247,19 @@ describe(`Utility: ${Caret.name}`, () => {
 
 			Caret.setIndex(element, 5)
 
-			expect(mockRange.setStart).not.toHaveBeenCalled()
-			expect(mockRange.setEnd).not.toHaveBeenCalled()
+			expect(mockSetStart).not.toHaveBeenCalled()
+			expect(mockAddRange).not.toHaveBeenCalled()
 		})
 
-		it('should do nothing when no range count', () => {
+		it('should do nothing when element has no text nodes', () => {
 			const element = document.createElement('div')
-
-			mockSelection.rangeCount = 0
 			mockGetSelection.mockReturnValue(mockSelection)
+			mockCreateTreeWalker.mockReturnValue({nextNode: () => null})
 
 			Caret.setIndex(element, 5)
 
-			expect(mockRange.setStart).not.toHaveBeenCalled()
-			expect(mockRange.setEnd).not.toHaveBeenCalled()
+			expect(mockSetStart).not.toHaveBeenCalled()
+			expect(mockAddRange).not.toHaveBeenCalled()
 		})
 	})
 
@@ -288,15 +315,13 @@ describe(`Utility: ${Caret.name}`, () => {
 	})
 
 	describe('setCaretToEnd', () => {
-		it('should set position to end of element', () => {
+		it('should set position to end of element by calling setIndex with Infinity', () => {
 			const element = document.createElement('div')
-			element.textContent = 'Hello'
-
-			mockGetSelection.mockReturnValue(mockSelection)
+			const setIndexSpy = vi.spyOn(Caret, 'setIndex')
 
 			Caret.setCaretToEnd(element)
 
-			expect(mockSelection.setPosition).toHaveBeenCalledWith(element, 1)
+			expect(setIndexSpy).toHaveBeenCalledWith(element, Infinity)
 		})
 
 		it('should do nothing when element is null', () => {
diff --git a/packages/common/core/src/features/caret/Caret.ts b/packages/common/core/src/features/caret/Caret.ts
index 5cf4268b..04e7be61 100644
--- a/packages/common/core/src/features/caret/Caret.ts
+++ b/packages/common/core/src/features/caret/Caret.ts
@@ -28,6 +28,77 @@ export class Caret {
 		return {left: 0, top: 0}
 	}
 
+	/** Returns the raw DOMRect of the current caret position, or null if unavailable. */
+	static getCaretRect(): DOMRect | null {
+		try {
+			const range = window.getSelection()?.getRangeAt(0)
+			return range?.getBoundingClientRect() ?? null
+		} catch {
+			return null
+		}
+	}
+
+	/**
+	 * Returns true if the caret is on the first visual line of the element.
+	 */
+	static isCaretOnFirstLine(element: HTMLElement): boolean {
+		const caretRect = this.getCaretRect()
+		if (!caretRect || caretRect.height === 0) return true
+		const elRect = element.getBoundingClientRect()
+		return caretRect.top < elRect.top + caretRect.height + 2
+	}
+
+	/**
+	 * Returns true if the caret is on the last visual line of the element.
+	 */
+	static isCaretOnLastLine(element: HTMLElement): boolean {
+		const caretRect = this.getCaretRect()
+		if (!caretRect || caretRect.height === 0) return true
+		const elRect = element.getBoundingClientRect()
+		return caretRect.bottom > elRect.bottom - caretRect.height - 2
+	}
+
+	/**
+	 * Positions the caret in `element` at the character closest to the given x coordinate.
+	 * `y` defaults to the vertical center of the element.
+	 */
+	static setAtX(element: HTMLElement, x: number, y?: number): void {
+		const elRect = element.getBoundingClientRect()
+		const targetY = y ?? elRect.top + elRect.height / 2
+
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		const caretPos =
+			(document as any).caretRangeFromPoint?.(x, targetY) ??
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			(document as any).caretPositionFromPoint?.(x, targetY)
+
+		if (!caretPos) return
+
+		const sel = window.getSelection()
+		if (!sel) return
+
+		let domRange: Range
+		if (caretPos instanceof Range) {
+			domRange = caretPos
+		} else if (caretPos && typeof caretPos === 'object' && 'offsetNode' in caretPos) {
+			// Firefox CaretPosition
+			domRange = document.createRange()
+			domRange.setStart(caretPos.offsetNode as Node, caretPos.offset as number)
+			domRange.collapse(true)
+		} else {
+			return
+		}
+
+		if (!element.contains(domRange.startContainer)) {
+			// Clicked outside: clamp to end
+			this.setIndex(element, Infinity)
+			return
+		}
+
+		sel.removeAllRanges()
+		sel.addRange(domRange)
+	}
+
 	static trySetIndex(element: HTMLElement, offset: number) {
 		try {
 			this.setIndex(element, offset)
@@ -36,13 +107,34 @@ export class Caret {
 		}
 	}
 
+	/**
+	 * Sets the caret at character `offset` within `element` by walking text nodes.
+	 * Use Infinity to position at the very end of all text.
+	 */
 	static setIndex(element: HTMLElement, offset: number) {
 		const selection = window.getSelection()
-		if (!selection?.anchorNode || !selection.rangeCount) return
+		if (!selection) return
 
-		const range = selection.getRangeAt(0)
-		range?.setStart(element.firstChild! || element, offset)
-		range?.setEnd(element.firstChild! || element, offset)
+		const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */)
+		let node = walker.nextNode() as Text | null
+		if (!node) return
+
+		let remaining = isFinite(offset) ? Math.max(0, offset) : Infinity
+
+		while (node) {
+			const next = walker.nextNode() as Text | null
+			if (!next || remaining <= node.length) {
+				const charOffset = isFinite(remaining) ? Math.min(remaining, node.length) : node.length
+				const range = document.createRange()
+				range.setStart(node, charOffset)
+				range.collapse(true)
+				selection.removeAllRanges()
+				selection.addRange(range)
+				return
+			}
+			remaining -= node.length
+			node = next
+		}
 	}
 
 	static getCaretIndex(element: HTMLElement) {
@@ -68,8 +160,7 @@ export class Caret {
 
 	static setCaretToEnd(element: HTMLElement | null | undefined) {
 		if (!element) return
-		const selection = window.getSelection()
-		selection?.setPosition(element, 1)
+		this.setIndex(element, Infinity)
 	}
 
 	static getIndex() {
diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts
index 1724669f..2aa20a77 100644
--- a/packages/common/core/src/features/input/KeyDownController.ts
+++ b/packages/common/core/src/features/input/KeyDownController.ts
@@ -1,8 +1,8 @@
 import type {NodeProxy} from '../../shared/classes/NodeProxy'
 import {KEYBOARD} from '../../shared/constants'
-import {deleteBlock} from '../blocks/blockOperations'
+import {deleteBlock, mergeBlocks} from '../blocks/blockOperations'
 import {BLOCK_SEPARATOR} from '../blocks/config'
-import {splitTokensIntoBlocks} from '../blocks/splitTokensIntoBlocks'
+import {splitTokensIntoBlocks, type Block} from '../blocks/splitTokensIntoBlocks'
 import {Caret} from '../caret'
 import {shiftFocusNext, shiftFocusPrev} from '../navigation'
 import {selectAllText} from '../selection'
@@ -27,6 +27,8 @@ export class KeyDownController {
 				shiftFocusPrev(this.store, e)
 			} else if (e.key === KEYBOARD.RIGHT) {
 				shiftFocusNext(this.store, e)
+			} else if (e.key === KEYBOARD.UP || e.key === KEYBOARD.DOWN) {
+				this.#handleArrowUpDown(e)
 			}
 
 			this.#handleDelete(e)
@@ -62,8 +64,11 @@ export class KeyDownController {
 
 	#handleDelete(event: KeyboardEvent) {
 		const {focus} = this.store.nodes
+		const isBlockMode = !!this.store.state.block.get()
 
-		if (event.key === KEYBOARD.DELETE || event.key === KEYBOARD.BACKSPACE) {
+		// Mark/span deletion only applies in non-block mode.
+		// In block mode the focus target is a block div, not a span/mark.
+		if (!isBlockMode && (event.key === KEYBOARD.DELETE || event.key === KEYBOARD.BACKSPACE)) {
 			if (focus.isMark) {
 				if (focus.isEditable) {
 					if (event.key === KEYBOARD.BACKSPACE && !focus.isCaretAtBeginning) return
@@ -78,6 +83,7 @@ export class KeyDownController {
 				if (focus.isSpan && focus.isCaretAtBeginning && focus.prev.target) {
 					event.preventDefault()
 					deleteMark('prev', this.store)
+					return
 				}
 			}
 
@@ -85,45 +91,92 @@ export class KeyDownController {
 				if (focus.isSpan && focus.isCaretAtEnd && focus.next.target) {
 					event.preventDefault()
 					deleteMark('next', this.store)
+					return
 				}
 			}
 		}
 
-		// Notion-like: Backspace on an empty block deletes the block
-		if (event.key === KEYBOARD.BACKSPACE && this.store.state.block.get()) {
-			const container = this.store.refs.container
-			if (!container) return
+		if (!isBlockMode) return
 
-			const blockDivs = Array.from(container.children)
-			const blockIndex = blockDivs.findIndex(
-				div => div === document.activeElement || div.contains(document.activeElement as Node)
-			)
-			if (blockIndex === -1) return
+		const container = this.store.refs.container
+		if (!container) return
+
+		const blockDivs = Array.from(container.children)
+		const blockIndex = blockDivs.findIndex(
+			div => div === document.activeElement || div.contains(document.activeElement as Node)
+		)
+		if (blockIndex === -1) return
 
-			const tokens = this.store.state.tokens.get()
-			const blocks = splitTokensIntoBlocks(tokens)
-			if (blockIndex >= blocks.length) return
+		const tokens = this.store.state.tokens.get()
+		const blocks = splitTokensIntoBlocks(tokens)
+		if (blockIndex >= blocks.length) return
 
-			const block = blocks[blockIndex]
+		const block = blocks[blockIndex]
+		const value = this.store.state.value.get() ?? this.store.state.previousValue.get() ?? ''
+		if (!this.store.state.onChange.get()) return
+
+		if (event.key === KEYBOARD.BACKSPACE) {
+			const blockDiv = blockDivs[blockIndex] as HTMLElement
+			const caretAtStart = Caret.getCaretIndex(blockDiv) === 0
+
+			// Empty block: delete the block entirely
 			const blockText = block.tokens.map(t => ('content' in t ? (t as {content: string}).content : '')).join('')
-			if (blockText !== '') return
+			if (blockText === '') {
+				event.preventDefault()
+				const newValue = deleteBlock(value, blocks, blockIndex)
+				this.store.applyValue(newValue)
+				queueMicrotask(() => {
+					const newDivs = container.children
+					const targetIndex = Math.max(0, blockIndex - 1)
+					const target = newDivs[targetIndex] as HTMLElement | undefined
+					if (target) {
+						target.focus()
+						Caret.setCaretToEnd(target)
+					}
+				})
+				return
+			}
 
-			event.preventDefault()
-			const value = this.store.state.value.get() ?? this.store.state.previousValue.get() ?? ''
-			if (!this.store.state.onChange.get()) return
-
-			const newValue = deleteBlock(value, blocks, blockIndex)
-			this.store.applyValue(newValue)
-
-			queueMicrotask(() => {
-				const newDivs = container.children
-				const targetIndex = Math.max(0, blockIndex - 1)
-				const target = newDivs[targetIndex] as HTMLElement | undefined
-				if (target) {
-					target.focus()
-					Caret.trySetIndex(target, Infinity)
-				}
-			})
+			// Non-empty block at position 0: merge with previous block
+			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
+			}
+		}
+
+		if (event.key === KEYBOARD.DELETE) {
+			const blockDiv = blockDivs[blockIndex] as HTMLElement
+			const caretAtEnd = Caret.getCaretIndex(blockDiv) === blockDiv.textContent?.length
+
+			// Caret at end of non-last block: merge next block into current
+			if (caretAtEnd && blockIndex < blocks.length - 1) {
+				event.preventDefault()
+				const joinPos = block.endPos
+				const newValue = mergeBlocks(value, blocks, blockIndex + 1)
+				this.store.applyValue(newValue)
+				queueMicrotask(() => {
+					const newDivs = container.children
+					const target = newDivs[blockIndex] as HTMLElement | undefined
+					if (target) {
+						target.focus()
+						const charOffset = joinPos - block.startPos
+						Caret.trySetIndex(target, charOffset)
+					}
+				})
+				return
+			}
 		}
 	}
 
@@ -151,19 +204,18 @@ export class KeyDownController {
 		}
 		if (blockIndex === -1) return
 
-		// Get caret offset within the active element
-		const caretOffset = Caret.getCaretIndex(activeElement)
-
-		// Compute absolute position in the full value string
 		const tokens = this.store.state.tokens.get()
 		const blocks = splitTokensIntoBlocks(tokens)
 		if (blockIndex >= blocks.length) return
 
 		const block = blocks[blockIndex]
+		const blockDiv = blockDivs[blockIndex] as HTMLElement
 		const value = this.store.state.value.get() ?? this.store.state.previousValue.get() ?? ''
-		const absolutePos = block.startPos + caretOffset
 
-		// Insert BLOCK_SEPARATOR at the absolute position
+		// Compute raw value offset at caret position using token positions
+		const absolutePos = getCaretRawPosInBlock(blockDiv, block)
+
+		// Insert BLOCK_SEPARATOR at the raw position
 		const newValue = value.slice(0, absolutePos) + BLOCK_SEPARATOR + value.slice(absolutePos)
 
 		if (!this.store.state.onChange.get()) return
@@ -180,6 +232,89 @@ export class KeyDownController {
 			}
 		})
 	}
+
+	#handleArrowUpDown(event: KeyboardEvent) {
+		if (!this.store.state.block.get()) return
+
+		const container = this.store.refs.container
+		if (!container) return
+
+		const activeElement = document.activeElement as HTMLElement | null
+		if (!activeElement || !container.contains(activeElement)) return
+
+		const blockDivs = Array.from(container.children)
+		const blockIndex = blockDivs.findIndex(div => div === activeElement || div.contains(activeElement))
+		if (blockIndex === -1) return
+
+		const blockDiv = blockDivs[blockIndex] as HTMLElement
+
+		if (event.key === KEYBOARD.UP) {
+			if (!Caret.isCaretOnFirstLine(blockDiv)) return
+			if (blockIndex === 0) return
+
+			event.preventDefault()
+			const caretRect = Caret.getCaretRect()
+			const caretX = caretRect?.left ?? blockDiv.getBoundingClientRect().left
+			const prevBlockDiv = blockDivs[blockIndex - 1] as HTMLElement
+			prevBlockDiv.focus()
+			const prevRect = prevBlockDiv.getBoundingClientRect()
+			Caret.setAtX(prevBlockDiv, caretX, prevRect.bottom - 4)
+		} else if (event.key === KEYBOARD.DOWN) {
+			if (!Caret.isCaretOnLastLine(blockDiv)) return
+			if (blockIndex >= blockDivs.length - 1) return
+
+			event.preventDefault()
+			const caretRect = Caret.getCaretRect()
+			const caretX = caretRect?.left ?? blockDiv.getBoundingClientRect().left
+			const nextBlockDiv = blockDivs[blockIndex + 1] as HTMLElement
+			nextBlockDiv.focus()
+			const nextRect = nextBlockDiv.getBoundingClientRect()
+			Caret.setAtX(nextBlockDiv, caretX, nextRect.top + 4)
+		}
+	}
+}
+
+/**
+ * Computes the raw value offset (index into the full value string) corresponding
+ * to the current caret position within `blockDiv`.
+ *
+ * For text tokens, the visual character offset equals the raw offset.
+ * For mark tokens, the caret is treated as being at the mark's end (after the mark).
+ */
+function getCaretRawPosInBlock(blockDiv: HTMLElement, block: Block): number {
+	const selection = window.getSelection()
+	if (!selection?.rangeCount) return block.endPos
+
+	const {focusNode} = selection
+	if (!focusNode) return block.endPos
+
+	// Walk up from focusNode to find the direct child of blockDiv that contains it
+	let node: Node | null = focusNode.nodeType === Node.ELEMENT_NODE ? focusNode : focusNode.parentElement
+	while (node && node.parentElement !== blockDiv) {
+		node = node.parentElement
+	}
+
+	if (!node) return block.endPos
+
+	// Find the child index in blockDiv.children (element children only)
+	const childIndex = Array.from(blockDiv.children).indexOf(node as Element)
+	if (childIndex < 0) return block.endPos
+
+	// Token index: child 0 is the drag handle, tokens start at child 1
+	const tokenIndex = childIndex - 1
+	if (tokenIndex < 0) return block.startPos // caret in drag handle
+	if (tokenIndex >= block.tokens.length) return block.endPos
+
+	const token = block.tokens[tokenIndex]
+
+	if (token.type === 'text') {
+		// Visual offset within this span = raw offset within this text token
+		const visualOffset = Caret.getCaretIndex(node as HTMLElement)
+		return token.position.start + visualOffset
+	}
+
+	// For mark tokens: treat the caret as being at the end of the mark
+	return token.position.end
 }
 
 export function handleBeforeInput(store: Store, event: InputEvent): void {
diff --git a/packages/common/core/src/features/navigation/index.ts b/packages/common/core/src/features/navigation/index.ts
index 33c8598f..edec22c3 100644
--- a/packages/common/core/src/features/navigation/index.ts
+++ b/packages/common/core/src/features/navigation/index.ts
@@ -1,15 +1,21 @@
+import {Caret} from '../caret'
 import type {Store} from '../store'
 
 export function shiftFocusPrev(store: Store, event: KeyboardEvent): boolean {
 	const {focus} = store.nodes
 	if ((focus.isMark && !focus.isEditable) || focus.isCaretAtBeginning) {
-		const prev = focus.prev
-		prev.focus()
-		if (!prev.isFocused) {
-			prev.prev.focus()
-			event.preventDefault()
+		// Walk back to the nearest focusable element (skip non-editable marks)
+		let prev = focus.prev
+		while (prev.target && prev.isMark && !prev.isEditable) {
+			prev = prev.prev
 		}
-		focus.setCaretToEnd()
+		if (!prev.target) return false
+
+		event.preventDefault()
+		prev.target.focus()
+		// After focusin fires, store.nodes.focus.target is updated to prev.target.
+		// Set caret at the end of the newly focused element.
+		Caret.setCaretToEnd(prev.target)
 		return true
 	}
 	return false
@@ -18,12 +24,17 @@ export function shiftFocusPrev(store: Store, event: KeyboardEvent): boolean {
 export function shiftFocusNext(store: Store, event: KeyboardEvent): boolean {
 	const {focus} = store.nodes
 	if ((focus.isMark && !focus.isEditable) || focus.isCaretAtEnd) {
-		const next = focus.next
-		next.focus()
-		if (!next.isFocused) {
-			next.next.focus()
-			event.preventDefault()
+		// Walk forward to the nearest focusable element (skip non-editable marks)
+		let next = focus.next
+		while (next.target && next.isMark && !next.isEditable) {
+			next = next.next
 		}
+		if (!next.target) return false
+
+		event.preventDefault()
+		next.target.focus()
+		// Set caret at the beginning of the newly focused element
+		Caret.trySetIndex(next.target, 0)
 		return true
 	}
 	return false

From b46e8b74a22d9697d44a4f39bd79203db9ed3ecc Mon Sep 17 00:00:00 2001
From: Nowely 
Date: Mon, 9 Mar 2026 18:46:19 +0300
Subject: [PATCH 21/25] fix(blocks): correct block handling for empty tokens
 and enhance block navigation

- Updated splitTokensIntoBlocks to ensure three empty blocks are created for two separators, improving block structure handling.
- Modified flushBlock logic to allow for empty block creation based on the canCreateEmpty flag, enhancing flexibility in block management.
- Enhanced BlockContainer to return an EMPTY_BLOCK when no tokens are present, ensuring consistent rendering and user experience.
- Added new tests for block keyboard navigation, verifying correct focus movement across blocks and merging behavior with Backspace/Delete.
---
 .../blocks/splitTokensIntoBlocks.spec.ts      |   3 +-
 .../features/blocks/splitTokensIntoBlocks.ts  |   2 +-
 .../src/features/input/KeyDownController.ts   |   5 +
 .../markput/src/components/BlockContainer.tsx |   7 +-
 .../storybook/src/pages/Block/Block.spec.tsx  | 269 +++++++++++++++++-
 5 files changed, 279 insertions(+), 7 deletions(-)

diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts
index 5589510b..b13508d0 100644
--- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts
+++ b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts
@@ -126,7 +126,8 @@ describe('splitTokensIntoBlocks', () => {
 	it('handles text with only double newlines', () => {
 		const tokens: Token[] = [text('\n\n\n\n', 0)]
 		const blocks = splitTokensIntoBlocks(tokens)
-		expect(blocks).toHaveLength(2)
+		// Two separators produce three empty blocks (leading + middle + trailing).
+		expect(blocks).toHaveLength(3)
 		blocks.forEach(b => {
 			expect(b.tokens).toHaveLength(0)
 			expect(b.startPos).toBe(b.endPos)
diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts
index 79f986c8..1a86eb25 100644
--- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts
+++ b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts
@@ -30,7 +30,7 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] {
 
 	const flushBlock = (endPos: number, canCreateEmpty = false) => {
 		const isEmpty = currentTokens.length === 0
-		if (blockStart === -1 && isEmpty) return
+		if (blockStart === -1 && isEmpty && !canCreateEmpty) return
 		if (isEmpty && !canCreateEmpty) return
 		const startPos = blockStart === -1 ? endPos : blockStart
 		blocks.push({
diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts
index 2aa20a77..e7b41986 100644
--- a/packages/common/core/src/features/input/KeyDownController.ts
+++ b/packages/common/core/src/features/input/KeyDownController.ts
@@ -331,6 +331,11 @@ export function handleBeforeInput(store: Store, event: InputEvent): void {
 	}
 	if (selecting === 'all') store.state.selecting.set(undefined)
 
+	// In block mode the focus target is a block div, not a text span.
+	// applySpanInput is not designed for block divs; keyboard operations
+	// (Enter, Backspace, Delete) are handled by KeyDownController instead.
+	if (store.state.block.get()) return
+
 	const {focus} = store.nodes
 	if (!focus.target || !focus.isEditable) return
 
diff --git a/packages/react/markput/src/components/BlockContainer.tsx b/packages/react/markput/src/components/BlockContainer.tsx
index ac804e96..848f4d4b 100644
--- a/packages/react/markput/src/components/BlockContainer.tsx
+++ b/packages/react/markput/src/components/BlockContainer.tsx
@@ -19,6 +19,8 @@ import {Token} from './Token'
 
 import styles from '@markput/core/styles.module.css'
 
+const EMPTY_BLOCK: Block = {id: 'block-empty', tokens: [], startPos: 0, endPos: 0}
+
 interface BlockMenuProps {
 	position: MenuPosition
 	onAdd: () => void
@@ -165,7 +167,10 @@ export const BlockContainer = memo(() => {
 	const ContainerComponent = useMemo(() => resolveSlot('container', slots), [slots])
 	const containerProps = useMemo(() => resolveSlotProps('container', slotProps), [slotProps])
 
-	const blocks = useMemo(() => splitTokensIntoBlocks(tokens), [tokens])
+	const blocks = useMemo(() => {
+		const result = splitTokensIntoBlocks(tokens)
+		return result.length > 0 ? result : [EMPTY_BLOCK]
+	}, [tokens])
 	const blocksRef = useRef(blocks)
 	blocksRef.current = blocks
 
diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx
index 53615690..c4407831 100644
--- a/packages/react/storybook/src/pages/Block/Block.spec.tsx
+++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx
@@ -3,7 +3,7 @@ import {describe, expect, it} from 'vitest'
 import {render} from 'vitest-browser-react'
 import {page, userEvent} from 'vitest/browser'
 
-import {focusAtEnd} from '../../shared/lib/focus'
+import {focusAtEnd, focusAtStart} from '../../shared/lib/focus'
 import * as BlockStories from './Block.stories'
 
 const {BasicDraggable, MarkdownDocument, PlainTextBlocks, ReadOnlyDraggable} = composeStories(BlockStories)
@@ -19,7 +19,11 @@ function getBlockDiv(grip: HTMLElement) {
 }
 
 function getEditableInBlock(blockDiv: HTMLElement) {
-	return blockDiv.querySelector('[contenteditable="true"]') as HTMLElement
+	return (blockDiv.querySelector('[contenteditable="true"]') ?? blockDiv) as HTMLElement
+}
+
+function getBlocks(container: Element) {
+	return Array.from(container.querySelectorAll('[data-testid="block"]'))
 }
 
 /** Read the raw value from the 
 rendered by the Text component */
@@ -370,8 +374,7 @@ describe('Feature: blocks', () => {
 	it('should split block at caret when pressing Enter at the beginning', async () => {
 		const {container} = await render()
 		const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
-		await userEvent.click(editable)
-		await userEvent.keyboard('{Home}')
+		await focusAtStart(editable)
 		await userEvent.keyboard('{Enter}')
 
 		expect(getGrips(container)).toHaveLength(6)
@@ -408,4 +411,262 @@ describe('Feature: blocks', () => {
 
 		expect(getRawValue(container)).toBe(original)
 	})
+})
+
+describe('Feature: block keyboard navigation', () => {
+	describe('ArrowLeft cross-block', () => {
+		it('should move focus to previous block when at start of block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtStart(getEditableInBlock(blocks[1]))
+			await userEvent.keyboard('{ArrowLeft}')
+
+			expect(document.activeElement).toBe(blocks[0])
+		})
+
+		it('should not cross to previous block when caret is mid-block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtEnd(getEditableInBlock(blocks[1]))
+			await userEvent.keyboard('{ArrowLeft}')
+
+			// Still in block 1
+			expect(document.activeElement).toBe(blocks[1])
+		})
+
+		it('should not cross block boundary from the first block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtStart(getEditableInBlock(blocks[0]))
+			await userEvent.keyboard('{ArrowLeft}')
+
+			expect(document.activeElement).toBe(blocks[0])
+		})
+	})
+
+	describe('ArrowRight cross-block', () => {
+		it('should move focus to next block when at end of block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtEnd(getEditableInBlock(blocks[0]))
+			await userEvent.keyboard('{ArrowRight}')
+
+			expect(document.activeElement).toBe(blocks[1])
+		})
+
+		it('should not cross to next block when caret is mid-block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtStart(getEditableInBlock(blocks[0]))
+			await userEvent.keyboard('{ArrowRight}')
+
+			// Still in block 0
+			expect(document.activeElement).toBe(blocks[0])
+		})
+
+		it('should not cross block boundary from the last block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			const last = blocks[blocks.length - 1]
+
+			await focusAtEnd(getEditableInBlock(last))
+			await userEvent.keyboard('{ArrowRight}')
+
+			expect(document.activeElement).toBe(last)
+		})
+	})
+
+	describe('ArrowDown cross-block', () => {
+		it('should move focus to next block when on last line of block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtEnd(getEditableInBlock(blocks[0]))
+			await userEvent.keyboard('{ArrowDown}')
+
+			expect(document.activeElement).toBe(blocks[1])
+		})
+
+		it('should not cross block boundary from the last block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			const last = blocks[blocks.length - 1]
+
+			await focusAtEnd(getEditableInBlock(last))
+			await userEvent.keyboard('{ArrowDown}')
+
+			expect(document.activeElement).toBe(last)
+		})
+	})
+
+	describe('ArrowUp cross-block', () => {
+		it('should move focus to previous block when on first line of block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtStart(getEditableInBlock(blocks[1]))
+			await userEvent.keyboard('{ArrowUp}')
+
+			expect(document.activeElement).toBe(blocks[0])
+		})
+
+		it('should not cross block boundary from the first block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			await focusAtStart(getEditableInBlock(blocks[0]))
+			await userEvent.keyboard('{ArrowUp}')
+
+			expect(document.activeElement).toBe(blocks[0])
+		})
+	})
+
+	describe('Backspace merge blocks', () => {
+		it('should merge with previous block when Backspace pressed at start of non-empty block', async () => {
+			const {container} = await render()
+			const before = getBlocks(container).length
+
+			await focusAtStart(getEditableInBlock(getBlocks(container)[1]))
+			await userEvent.keyboard('{Backspace}')
+
+			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('{Backspace}')
+
+			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 merge', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			const prevBlock = blocks[0]
+
+			await focusAtStart(getEditableInBlock(blocks[1]))
+			await userEvent.keyboard('{Backspace}')
+
+			expect(document.activeElement).toBe(prevBlock)
+		})
+
+		it('should only delete one block at a time on Backspace', async () => {
+			const {container} = await render()
+			expect(getBlocks(container)).toHaveLength(5)
+
+			await focusAtStart(getEditableInBlock(getBlocks(container)[1]))
+			await userEvent.keyboard('{Backspace}')
+
+			// Must be exactly 4 — not 3 (double-delete regression guard)
+			expect(getBlocks(container)).toHaveLength(4)
+		})
+	})
+
+	describe('Delete merge blocks', () => {
+		it('should merge with next block when Delete pressed at end of non-last block', async () => {
+			const {container} = await render()
+			const before = getBlocks(container).length
+
+			await focusAtEnd(getEditableInBlock(getBlocks(container)[0]))
+			await userEvent.keyboard('{Delete}')
+
+			expect(getBlocks(container)).toHaveLength(before - 1)
+		})
+
+		it('should preserve content of both merged blocks', async () => {
+			const {container} = await render()
+
+			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')
+		})
+
+		it('should keep focus in the current block after Delete merge', async () => {
+			const {container} = await render()
+			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()
+			const blocks = getBlocks(container)
+			const last = blocks[blocks.length - 1]
+
+			await focusAtEnd(getEditableInBlock(last))
+			await userEvent.keyboard('{Delete}')
+
+			expect(getBlocks(container)).toHaveLength(5)
+		})
+	})
+
+	describe('Enter mid-block split', () => {
+		it('should increase block count by 1', async () => {
+			const {container} = await render()
+
+			const editable = getEditableInBlock(getBlocks(container)[0])
+			await userEvent.click(editable)
+			await userEvent.keyboard('{Home}')
+			await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}')
+			await userEvent.keyboard('{Enter}')
+
+			expect(getBlocks(container)).toHaveLength(6)
+		})
+
+		it('should put text before caret in current block', async () => {
+			const {container} = await render()
+
+			const editable = getEditableInBlock(getBlocks(container)[0])
+			await userEvent.click(editable)
+			await userEvent.keyboard('{Home}')
+			// Position after "First"
+			await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}')
+			await userEvent.keyboard('{Enter}')
+
+			const raw = getRawValue(container)
+			const blockTexts = raw.split('\n\n')
+			expect(blockTexts[0]).toBe('First')
+		})
+
+		it('should put text after caret in new block', async () => {
+			const {container} = await render()
+
+			const editable = getEditableInBlock(getBlocks(container)[0])
+			await userEvent.click(editable)
+			await userEvent.keyboard('{Home}')
+			// Position after "First"
+			await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}')
+			await userEvent.keyboard('{Enter}')
+
+			const raw = getRawValue(container)
+			const blockTexts = raw.split('\n\n')
+			expect(blockTexts[1]).toBe(' block of plain text')
+		})
+
+		it('should not expose raw markdown syntax in block[0] after Enter with marks', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			await focusAtEnd(blocks[0])
+			await userEvent.keyboard('{Enter}')
+
+			const raw = getRawValue(container)
+			// The separator after the mark should still be intact
+			expect(raw).toContain('**Marked Input**\n\n')
+		})
+	})
 })
\ No newline at end of file

From ea77df62c62572e9c933069e642e652b053f2e5f Mon Sep 17 00:00:00 2001
From: Nowely 
Date: Mon, 9 Mar 2026 19:27:07 +0300
Subject: [PATCH 22/25] fix(blocks): enhance block input handling and selection
 behavior

- Implemented `handleBlockBeforeInput` to manage text insertions and deletions in block mode, ensuring proper state updates.
- Updated `selectAllText` to allow native browser handling of Ctrl+A in block mode, preventing unintended selection across blocks.
- Added tests to verify correct behavior for typing and deleting within blocks, addressing previous bugs related to raw value updates and selection management.
---
 .../src/features/input/KeyDownController.ts   | 110 +++++++++++++++++-
 .../core/src/features/selection/index.ts      |   4 +
 .../storybook/src/pages/Block/Block.spec.tsx  |  35 ++++++
 3 files changed, 146 insertions(+), 3 deletions(-)

diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts
index e7b41986..b150b506 100644
--- a/packages/common/core/src/features/input/KeyDownController.ts
+++ b/packages/common/core/src/features/input/KeyDownController.ts
@@ -332,9 +332,12 @@ export function handleBeforeInput(store: Store, event: InputEvent): void {
 	if (selecting === 'all') store.state.selecting.set(undefined)
 
 	// In block mode the focus target is a block div, not a text span.
-	// applySpanInput is not designed for block divs; keyboard operations
-	// (Enter, Backspace, Delete) are handled by KeyDownController instead.
-	if (store.state.block.get()) return
+	// Block-level keys (Enter, Backspace, Delete) are handled by KeyDownController.
+	// Text insertions and in-block deletions need special handling to update state.
+	if (store.state.block.get()) {
+		handleBlockBeforeInput(store, event)
+		return
+	}
 
 	const {focus} = store.nodes
 	if (!focus.target || !focus.isEditable) return
@@ -450,4 +453,105 @@ export function replaceAllContentWith(store: Store, newContent: string): void {
 			firstChild.focus()
 		}
 	})
+}
+
+/**
+ * Handles `beforeinput` events when the editor is in block mode.
+ * Intercepts text insertion and in-block deletion to update the raw value via
+ * `store.applyValue`, since `applySpanInput` is designed for span-level editing only.
+ * Block-level operations (Enter, Backspace/Delete at boundaries) are handled by
+ * `KeyDownController` via `keydown` events.
+ */
+function handleBlockBeforeInput(store: Store, event: InputEvent): void {
+	const container = store.refs.container
+	if (!container) return
+
+	const activeElement = document.activeElement as HTMLElement | null
+	if (!activeElement || !container.contains(activeElement)) return
+
+	const blockDivs = Array.from(container.children) as HTMLElement[]
+	const blockIndex = blockDivs.findIndex(div => div === activeElement || div.contains(activeElement))
+	if (blockIndex === -1) return
+
+	const blockDiv = blockDivs[blockIndex]
+	const tokens = store.state.tokens.get()
+	const blocks = splitTokensIntoBlocks(tokens)
+	if (blockIndex >= blocks.length) return
+
+	const block = blocks[blockIndex]
+	const value = store.state.value.get() ?? store.state.previousValue.get() ?? ''
+
+	switch (event.inputType) {
+		case 'insertText': {
+			event.preventDefault()
+			const data = event.data ?? ''
+			const ranges = event.getTargetRanges()
+			if (!ranges.length) return
+			const rawStart = getDomRawPos(ranges[0].startContainer, ranges[0].startOffset, blockDiv, block)
+			const rawEnd = getDomRawPos(ranges[0].endContainer, ranges[0].endOffset, blockDiv, block)
+			const [rawFrom, rawTo] = rawStart <= rawEnd ? [rawStart, rawEnd] : [rawEnd, rawStart]
+			store.applyValue(value.slice(0, rawFrom) + data + value.slice(rawTo))
+			const newCaretOffset = rawFrom - block.startPos + data.length
+			queueMicrotask(() => {
+				const target = container.children[blockIndex] as HTMLElement | undefined
+				if (target) {
+					target.focus()
+					Caret.trySetIndex(target, newCaretOffset)
+				}
+			})
+			break
+		}
+		case 'deleteContentBackward':
+		case 'deleteContentForward':
+		case 'deleteWordBackward':
+		case 'deleteWordForward':
+		case 'deleteSoftLineBackward':
+		case 'deleteSoftLineForward': {
+			const ranges = event.getTargetRanges()
+			if (!ranges.length) return
+			const rawStart = getDomRawPos(ranges[0].startContainer, ranges[0].startOffset, blockDiv, block)
+			const rawEnd = getDomRawPos(ranges[0].endContainer, ranges[0].endOffset, blockDiv, block)
+			const [rawFrom, rawTo] = rawStart <= rawEnd ? [rawStart, rawEnd] : [rawEnd, rawStart]
+			if (rawFrom === rawTo) return
+			event.preventDefault()
+			store.applyValue(value.slice(0, rawFrom) + value.slice(rawTo))
+			const newCaretOffset = rawFrom - block.startPos
+			queueMicrotask(() => {
+				const target = container.children[blockIndex] as HTMLElement | undefined
+				if (target) {
+					target.focus()
+					Caret.trySetIndex(target, newCaretOffset)
+				}
+			})
+			break
+		}
+	}
+}
+
+/**
+ * Maps a DOM (node, offset) position to an absolute raw-value offset.
+ * Walks up from `node` to find the direct child of `blockDiv`, then maps
+ * to the corresponding token's raw position.
+ */
+function getDomRawPos(node: Node, offset: number, blockDiv: HTMLElement, block: Block): number {
+	let child: Node | null = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
+	while (child && child.parentElement !== blockDiv) {
+		child = child.parentElement
+	}
+	if (!child) return block.endPos
+
+	const childIndex = Array.from(blockDiv.children).indexOf(child as Element)
+	if (childIndex < 0) return block.endPos
+
+	// child[0] is the side panel div (drag handle); tokens start at child[1]
+	const tokenIndex = childIndex - 1
+	if (tokenIndex < 0) return block.startPos
+	if (tokenIndex >= block.tokens.length) return block.endPos
+
+	const token = block.tokens[tokenIndex]
+	if (token.type === 'text') {
+		return token.position.start + Math.min(offset, token.content.length)
+	}
+	// For mark tokens: offset 0 means before the mark, any other offset means after
+	return offset === 0 ? token.position.start : token.position.end
 }
\ No newline at end of file
diff --git a/packages/common/core/src/features/selection/index.ts b/packages/common/core/src/features/selection/index.ts
index 1c9e6592..cbcbfb34 100644
--- a/packages/common/core/src/features/selection/index.ts
+++ b/packages/common/core/src/features/selection/index.ts
@@ -19,6 +19,10 @@ export function isFullSelection(store: Store): boolean {
 
 export function selectAllText(store: Store, event: KeyboardEvent): void {
 	if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') {
+		// In block mode, let the browser handle Ctrl+A natively so it selects
+		// text within the focused block only, not across all blocks.
+		if (store.state.block.get()) return
+
 		event.preventDefault()
 
 		const selection = window.getSelection()
diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx
index c4407831..8518c8ab 100644
--- a/packages/react/storybook/src/pages/Block/Block.spec.tsx
+++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx
@@ -615,6 +615,41 @@ describe('Feature: block keyboard navigation', () => {
 		})
 	})
 
+	describe('typing in blocks (BUG3)', () => {
+		it('should update raw value when typing a character at end of block', async () => {
+			const {container} = await render()
+			await focusAtEnd(getEditableInBlock(getBlocks(container)[0]))
+			await userEvent.keyboard('!')
+
+			expect(getRawValue(container)).toContain('First block of plain text!')
+		})
+
+		it('should update raw value when deleting a character with Backspace mid-block', async () => {
+			const {container} = await render()
+			await focusAtEnd(getEditableInBlock(getBlocks(container)[0]))
+			await userEvent.keyboard('{Backspace}')
+
+			// "First block of plain text" → backspace → "First block of plain tex"
+			expect(getRawValue(container)).toContain('First block of plain tex')
+			expect(getRawValue(container)).not.toContain('First block of plain text\n\n')
+		})
+
+		it('should not wipe all blocks when Ctrl+A in focused block then typing (BUG1)', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+
+			// Focus block 1 and Ctrl+A — bug sets selecting='all' and replaces all content on next keystroke
+			getEditableInBlock(blocks[1]).focus()
+			await userEvent.keyboard('{Control>}a{/Control}')
+			await userEvent.keyboard('X')
+
+			// With bug: raw value becomes 'X' (all wiped) and first block content gone
+			// After fix: first block unchanged, only block 1 affected
+			expect(getRawValue(container)).not.toBe('X')
+			expect(getRawValue(container)).toContain('First block of plain text')
+		})
+	})
+
 	describe('Enter mid-block split', () => {
 		it('should increase block count by 1', async () => {
 			const {container} = await render()

From 50b64de907dbb968c0e90135cfa0cc25586a227f Mon Sep 17 00:00:00 2001
From: Nowely 
Date: Mon, 9 Mar 2026 22:09:58 +0300
Subject: [PATCH 23/25] fix(blocks): improve caret positioning and input
 handling in block editor

- Refactored getCaretRawPosInBlock to streamline caret position calculation, ensuring accurate placement within text and mark tokens.
- Enhanced handleBlockBeforeInput to correctly manage text insertions and deletions, addressing issues with caret behavior at mark boundaries.
- Introduced focusAndSetCaret function to simplify caret management after input events.
- Added tests for new input handling scenarios, verifying correct behavior for typing and pasting in blocks, particularly with mark tokens.
---
 .../src/features/input/KeyDownController.ts   | 192 +++++++++++++-----
 .../storybook/src/pages/Block/Block.spec.tsx  | 118 +++++++++++
 2 files changed, 258 insertions(+), 52 deletions(-)

diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts
index b150b506..00c61332 100644
--- a/packages/common/core/src/features/input/KeyDownController.ts
+++ b/packages/common/core/src/features/input/KeyDownController.ts
@@ -5,6 +5,7 @@ import {BLOCK_SEPARATOR} from '../blocks/config'
 import {splitTokensIntoBlocks, type Block} from '../blocks/splitTokensIntoBlocks'
 import {Caret} from '../caret'
 import {shiftFocusNext, shiftFocusPrev} from '../navigation'
+import type {MarkToken} from '../parsing/ParserV2/types'
 import {selectAllText} from '../selection'
 import type {Store} from '../store/Store'
 import {deleteMark} from '../text-manipulation'
@@ -278,43 +279,16 @@ export class KeyDownController {
  * Computes the raw value offset (index into the full value string) corresponding
  * to the current caret position within `blockDiv`.
  *
- * For text tokens, the visual character offset equals the raw offset.
- * For mark tokens, the caret is treated as being at the mark's end (after the mark).
+ * Delegates to `getDomRawPos` using the current selection's focus node and offset.
  */
 function getCaretRawPosInBlock(blockDiv: HTMLElement, block: Block): number {
 	const selection = window.getSelection()
 	if (!selection?.rangeCount) return block.endPos
 
-	const {focusNode} = selection
+	const {focusNode, focusOffset} = selection
 	if (!focusNode) return block.endPos
 
-	// Walk up from focusNode to find the direct child of blockDiv that contains it
-	let node: Node | null = focusNode.nodeType === Node.ELEMENT_NODE ? focusNode : focusNode.parentElement
-	while (node && node.parentElement !== blockDiv) {
-		node = node.parentElement
-	}
-
-	if (!node) return block.endPos
-
-	// Find the child index in blockDiv.children (element children only)
-	const childIndex = Array.from(blockDiv.children).indexOf(node as Element)
-	if (childIndex < 0) return block.endPos
-
-	// Token index: child 0 is the drag handle, tokens start at child 1
-	const tokenIndex = childIndex - 1
-	if (tokenIndex < 0) return block.startPos // caret in drag handle
-	if (tokenIndex >= block.tokens.length) return block.endPos
-
-	const token = block.tokens[tokenIndex]
-
-	if (token.type === 'text') {
-		// Visual offset within this span = raw offset within this text token
-		const visualOffset = Caret.getCaretIndex(node as HTMLElement)
-		return token.position.start + visualOffset
-	}
-
-	// For mark tokens: treat the caret as being at the end of the mark
-	return token.position.end
+	return getDomRawPos(focusNode, focusOffset, blockDiv, block)
 }
 
 export function handleBeforeInput(store: Store, event: InputEvent): void {
@@ -481,24 +455,56 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void {
 	const block = blocks[blockIndex]
 	const value = store.state.value.get() ?? store.state.previousValue.get() ?? ''
 
+	const focusAndSetCaret = (newRawPos: number) => {
+		queueMicrotask(() => {
+			const target = container.children[blockIndex] as HTMLElement | undefined
+			if (!target) return
+			target.focus()
+			// Use updated tokens (post-applyValue) for correct token positions
+			const updatedBlocks = splitTokensIntoBlocks(store.state.tokens.get())
+			const updatedBlock = updatedBlocks[blockIndex]
+			if (updatedBlock) setCaretAtRawPos(target, updatedBlock, newRawPos)
+		})
+	}
+
 	switch (event.inputType) {
 		case 'insertText': {
 			event.preventDefault()
 			const data = event.data ?? ''
 			const ranges = event.getTargetRanges()
-			if (!ranges.length) return
-			const rawStart = getDomRawPos(ranges[0].startContainer, ranges[0].startOffset, blockDiv, block)
-			const rawEnd = getDomRawPos(ranges[0].endContainer, ranges[0].endOffset, blockDiv, block)
-			const [rawFrom, rawTo] = rawStart <= rawEnd ? [rawStart, rawEnd] : [rawEnd, rawStart]
+			let rawFrom: number
+			let rawTo: number
+			if (ranges.length > 0) {
+				const rawStart = getDomRawPos(ranges[0].startContainer, ranges[0].startOffset, blockDiv, block)
+				const rawEnd = getDomRawPos(ranges[0].endContainer, ranges[0].endOffset, blockDiv, block)
+				;[rawFrom, rawTo] = rawStart <= rawEnd ? [rawStart, rawEnd] : [rawEnd, rawStart]
+			} else {
+				// getTargetRanges() can be empty when the caret is adjacent to a mark element.
+				// Fall back to reading the caret position directly from the selection.
+				rawFrom = rawTo = getCaretRawPosInBlock(blockDiv, block)
+			}
 			store.applyValue(value.slice(0, rawFrom) + data + value.slice(rawTo))
-			const newCaretOffset = rawFrom - block.startPos + data.length
-			queueMicrotask(() => {
-				const target = container.children[blockIndex] as HTMLElement | undefined
-				if (target) {
-					target.focus()
-					Caret.trySetIndex(target, newCaretOffset)
-				}
-			})
+			focusAndSetCaret(rawFrom + data.length)
+			break
+		}
+		case 'insertFromPaste':
+		case 'insertReplacementText': {
+			event.preventDefault()
+			const pasteData = event.dataTransfer?.getData('text/plain') ?? ''
+			const ranges = event.getTargetRanges()
+			let rawFrom: number
+			let rawTo: number
+			if (ranges.length > 0) {
+				const rawStart = getDomRawPos(ranges[0].startContainer, ranges[0].startOffset, blockDiv, block)
+				const rawEnd = getDomRawPos(ranges[0].endContainer, ranges[0].endOffset, blockDiv, block)
+				;[rawFrom, rawTo] = rawStart <= rawEnd ? [rawStart, rawEnd] : [rawEnd, rawStart]
+			} else {
+				// Fall back to current selection when target ranges are unavailable
+				// (e.g. synthetic events used in tests, or caret adjacent to mark elements).
+				rawFrom = rawTo = getCaretRawPosInBlock(blockDiv, block)
+			}
+			store.applyValue(value.slice(0, rawFrom) + pasteData + value.slice(rawTo))
+			focusAndSetCaret(rawFrom + pasteData.length)
 			break
 		}
 		case 'deleteContentBackward':
@@ -515,23 +521,59 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void {
 			if (rawFrom === rawTo) return
 			event.preventDefault()
 			store.applyValue(value.slice(0, rawFrom) + value.slice(rawTo))
-			const newCaretOffset = rawFrom - block.startPos
-			queueMicrotask(() => {
-				const target = container.children[blockIndex] as HTMLElement | undefined
-				if (target) {
-					target.focus()
-					Caret.trySetIndex(target, newCaretOffset)
+			focusAndSetCaret(rawFrom)
+			break
+		}
+	}
+}
+
+/**
+ * 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).
+ */
+function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: number): void {
+	const sel = window.getSelection()
+	if (!sel) return
+
+	const blockChildren = Array.from(blockDiv.children)
+
+	for (let i = 0; i < block.tokens.length; i++) {
+		const token = block.tokens[i]
+		// child[0] is the side panel; token[i] maps to child[i+1]
+		const domChild = blockChildren[i + 1] as HTMLElement | undefined
+		if (!domChild) continue
+
+		if (rawAbsolutePos >= token.position.start && rawAbsolutePos <= token.position.end) {
+			if (token.type === 'text') {
+				const offsetWithinToken = rawAbsolutePos - token.position.start
+				const walker = document.createTreeWalker(domChild, 4 /* SHOW_TEXT */)
+				const textNode = walker.nextNode() as Text | null
+				if (textNode) {
+					const charOffset = Math.min(offsetWithinToken, textNode.length)
+					const range = document.createRange()
+					range.setStart(textNode, charOffset)
+					range.collapse(true)
+					sel.removeAllRanges()
+					sel.addRange(range)
+					return
 				}
-			})
+			}
+			// Mark token: fall through to end-of-block fallback
 			break
 		}
 	}
+
+	// Fallback: position caret at end of block
+	Caret.setCaretToEnd(blockDiv)
 }
 
 /**
  * Maps a DOM (node, offset) position to an absolute raw-value offset.
  * Walks up from `node` to find the direct child of `blockDiv`, then maps
- * to the corresponding token's raw position.
+ * to the corresponding token's raw position. For mark tokens with nested
+ * content, recursively resolves the position within the mark's children.
  */
 function getDomRawPos(node: Node, offset: number, blockDiv: HTMLElement, block: Block): number {
 	let child: Node | null = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
@@ -552,6 +594,52 @@ function getDomRawPos(node: Node, offset: number, blockDiv: HTMLElement, block:
 	if (token.type === 'text') {
 		return token.position.start + Math.min(offset, token.content.length)
 	}
-	// For mark tokens: offset 0 means before the mark, any other offset means after
-	return offset === 0 ? token.position.start : token.position.end
+	// For mark tokens: recursively resolve position within nested mark structure
+	return getDomRawPosInMark(node, offset, child as HTMLElement, token)
+}
+
+/**
+ * Recursively maps a DOM (node, offset) position to an absolute raw-value offset
+ * within a mark token's nested content. Handles marks that contain other marks
+ * (e.g. h1 containing bold), correctly mapping cursor positions through the
+ * nested DOM/token structure.
+ *
+ * Key rules:
+ * - cursor at offset === 0 → raw position at mark start
+ * - cursor at offset === full nested length → raw position at mark end (after closing delimiter)
+ * - cursor in the middle → raw position within nested content
+ */
+function getDomRawPosInMark(node: Node, offset: number, markElement: HTMLElement, markToken: MarkToken): number {
+	if (!markToken.children || markToken.children.length === 0) {
+		// Leaf mark (no nested parsed children): use nested content boundaries
+		if (offset === 0) return markToken.position.start
+		const nestedLen = markToken.nested?.content.length ?? markToken.value.length
+		// Cursor at or past end of nested content → after closing delimiter
+		if (nestedLen > 0 && offset >= nestedLen) return markToken.position.end
+		// Cursor in the middle of nested content
+		return (markToken.nested?.start ?? markToken.position.start) + Math.min(offset, nestedLen)
+	}
+
+	// Walk child nodes of markElement and match to token children.
+	// TextToken children render as text nodes; MarkToken children render as elements.
+	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 (node === childNode) {
+				return tokenChild.position.start + Math.min(offset, tokenChild.content.length)
+			}
+			tokenIdx++
+		} else if (childNode.nodeType === Node.ELEMENT_NODE && tokenChild.type === 'mark') {
+			if (childNode === node || (childNode as Element).contains(node)) {
+				return getDomRawPosInMark(node, offset, childNode as HTMLElement, tokenChild)
+			}
+			tokenIdx++
+		}
+	}
+
+	// Fallback: cursor at or beyond end of nested content
+	return markToken.nested?.end ?? markToken.position.end
 }
\ No newline at end of file
diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx
index 8518c8ab..0997810d 100644
--- a/packages/react/storybook/src/pages/Block/Block.spec.tsx
+++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx
@@ -413,6 +413,52 @@ describe('Feature: blocks', () => {
 	})
 })
 
+/** Dispatch a synthetic beforeinput paste event using the current selection as the target range. */
+function dispatchPaste(target: HTMLElement, text: string) {
+	const sel = window.getSelection()
+	if (!sel?.rangeCount) return
+	const r = sel.getRangeAt(0)
+	const staticRange = new StaticRange({
+		startContainer: r.startContainer,
+		startOffset: r.startOffset,
+		endContainer: r.endContainer,
+		endOffset: r.endOffset,
+	})
+	const dt = new DataTransfer()
+	dt.setData('text/plain', text)
+	target.dispatchEvent(
+		new InputEvent('beforeinput', {
+			bubbles: true,
+			cancelable: true,
+			inputType: 'insertFromPaste',
+			dataTransfer: dt,
+			targetRanges: [staticRange],
+		})
+	)
+}
+
+/** Dispatch a synthetic beforeinput insertText event using the current selection as the target range. */
+function dispatchInsertText(target: HTMLElement, text: string) {
+	const sel = window.getSelection()
+	if (!sel?.rangeCount) return
+	const r = sel.getRangeAt(0)
+	const staticRange = new StaticRange({
+		startContainer: r.startContainer,
+		startOffset: r.startOffset,
+		endContainer: r.endContainer,
+		endOffset: r.endOffset,
+	})
+	target.dispatchEvent(
+		new InputEvent('beforeinput', {
+			bubbles: true,
+			cancelable: true,
+			inputType: 'insertText',
+			data: text,
+			targetRanges: [staticRange],
+		})
+	)
+}
+
 describe('Feature: block keyboard navigation', () => {
 	describe('ArrowLeft cross-block', () => {
 		it('should move focus to previous block when at start of block', async () => {
@@ -648,6 +694,78 @@ describe('Feature: block keyboard navigation', () => {
 			expect(getRawValue(container)).not.toBe('X')
 			expect(getRawValue(container)).toContain('First block of plain text')
 		})
+
+		it('should append character after last mark when typing at end of mark block (BUG-CARET-MARK)', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			// block[0] raw = '# Welcome to **Marked Input**'
+			await focusAtEnd(blocks[0])
+			// Use synthetic event to avoid browser selection drift at mark boundaries
+			dispatchInsertText(blocks[0], '!')
+			await new Promise(r => setTimeout(r, 50))
+
+			// With the bug: raw/visual offset mismatch puts '!' mid-mark, e.g. '# Welcome to **Marked Inpu!t**'
+			// After fix: '!' appended after the last mark at correct position
+			const block0Raw = getRawValue(container).split('\n\n')[0]
+			expect(block0Raw).toBe('# Welcome to **Marked Input**!')
+		})
+
+		it('should insert character at correct position mid-text within a mark block (BUG-CARET-MARK)', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			// block[0] raw = '# Welcome to **Marked Input**'
+			// block[0] h1 renders nested children (no '# ' prefix visible):
+			//   visual text = 'Welcome to Marked Input' (23 chars)
+			// focusAtStart → cursor before 'W' (raw pos 2, after h1 prefix '# ')
+			// ArrowRight x2 → cursor 2 visual positions right → before 'l' (raw pos 4)
+			await focusAtStart(blocks[0])
+			await userEvent.keyboard('{ArrowRight}{ArrowRight}')
+			// Use synthetic event to capture selection before browser can drift
+			dispatchInsertText(blocks[0], 'X')
+			await new Promise(r => setTimeout(r, 50))
+
+			// With the bug: getDomRawPos returned token.position.end = 31 for any mark cursor,
+			// inserting X at start of block 1. After fix: X at raw pos 4 (within nested TextToken).
+			const block0Raw = getRawValue(container).split('\n\n')[0]
+			expect(block0Raw).toBe('# WeXlcome to **Marked Input**')
+		})
+	})
+
+	describe('paste in blocks (BUG-PASTE)', () => {
+		it('should update raw value when pasting text at end of a plain text block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			await focusAtEnd(getEditableInBlock(blocks[0]))
+			dispatchPaste(blocks[0], ' pasted')
+			await new Promise(r => setTimeout(r, 50))
+
+			expect(getRawValue(container)).toContain('First block of plain text pasted')
+		})
+
+		it('should not affect other blocks when pasting in one block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			await focusAtEnd(getEditableInBlock(blocks[0]))
+			dispatchPaste(blocks[0], '!')
+			await new Promise(r => setTimeout(r, 50))
+
+			const raw = getRawValue(container)
+			expect(raw).toContain('Second block of plain text')
+			expect(raw).toContain('Fifth block of plain text')
+			expect(getBlocks(container)).toHaveLength(5)
+		})
+
+		it('should update raw value when pasting text at end of a mark block', async () => {
+			const {container} = await render()
+			const blocks = getBlocks(container)
+			// block[0] raw = '# Welcome to **Marked Input**'
+			await focusAtEnd(blocks[0])
+			dispatchPaste(blocks[0], '!')
+			await new Promise(r => setTimeout(r, 50))
+
+			const block0Raw = getRawValue(container).split('\n\n')[0]
+			expect(block0Raw).toBe('# Welcome to **Marked Input**!')
+		})
 	})
 
 	describe('Enter mid-block split', () => {

From c070e24413f13b73cb5f0ad324c3a78618c73c68 Mon Sep 17 00:00:00 2001
From: Nowely 
Date: Tue, 10 Mar 2026 00:28:50 +0300
Subject: [PATCH 24/25] fix(blocks): improve caret positioning and handling in
 KeyDownController

- Enhanced getDomRawPos to provide more accurate caret positioning at element boundaries, particularly when dealing with comment nodes.
- Updated getDomRawPosInMark to correctly map cursor positions when marks end with block separators, ensuring proper placement of the caret.
- Added a data-testid attribute to DraggableBlock for improved testing capabilities.
- Introduced comprehensive tests for block rendering and interaction scenarios in the new Block.spec.ts file, validating the expected behavior across various use cases.
---
 .../src/features/input/KeyDownController.ts   |  26 +-
 .../markput/src/components/DraggableBlock.vue |   1 +
 .../storybook/src/pages/Block/Block.spec.ts   | 776 ++++++++++++++++++
 .../src/pages/Block/Block.stories.ts          |  57 +-
 4 files changed, 846 insertions(+), 14 deletions(-)
 create mode 100644 packages/vue/storybook/src/pages/Block/Block.spec.ts

diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts
index 00c61332..d3f898b3 100644
--- a/packages/common/core/src/features/input/KeyDownController.ts
+++ b/packages/common/core/src/features/input/KeyDownController.ts
@@ -576,6 +576,18 @@ function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: n
  * content, recursively resolves the position within the mark's children.
  */
 function getDomRawPos(node: Node, offset: number, blockDiv: HTMLElement, block: Block): number {
+	// When the browser represents the caret as (blockDiv, childNodeOffset) — common
+	// at element boundaries (e.g. after a mark span in Vue where comment nodes
+	// shift childNode indices) — fall back to the current selection which gives
+	// a more precise (textNode, offset) position.
+	if (node === blockDiv) {
+		const sel = window.getSelection()
+		if (sel?.focusNode && sel.focusNode !== blockDiv) {
+			return getDomRawPos(sel.focusNode, sel.focusOffset, blockDiv, block)
+		}
+		return block.endPos
+	}
+
 	let child: Node | null = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
 	while (child && child.parentElement !== blockDiv) {
 		child = child.parentElement
@@ -611,12 +623,18 @@ function getDomRawPos(node: Node, offset: number, blockDiv: HTMLElement, block:
  */
 function getDomRawPosInMark(node: Node, offset: number, markElement: HTMLElement, markToken: MarkToken): number {
 	if (!markToken.children || markToken.children.length === 0) {
-		// Leaf mark (no nested parsed children): use nested content boundaries
 		if (offset === 0) return markToken.position.start
 		const nestedLen = markToken.nested?.content.length ?? markToken.value.length
-		// Cursor at or past end of nested content → after closing delimiter
-		if (nestedLen > 0 && offset >= nestedLen) return markToken.position.end
-		// Cursor in the middle of nested content
+		if (nestedLen > 0 && offset >= nestedLen) {
+			// When the mark's raw content ends with a block separator, the cursor
+			// at the visual end should map to nested.end (before the separator),
+			// not position.end (after it). Otherwise use position.end to place
+			// the cursor after the closing delimiter (e.g. ** in bold).
+			if (markToken.content.endsWith('\n\n') && markToken.nested) {
+				return markToken.nested.end
+			}
+			return markToken.position.end
+		}
 		return (markToken.nested?.start ?? markToken.position.start) + Math.min(offset, nestedLen)
 	}
 
diff --git a/packages/vue/markput/src/components/DraggableBlock.vue b/packages/vue/markput/src/components/DraggableBlock.vue
index f3fb365a..6f4509ec 100644
--- a/packages/vue/markput/src/components/DraggableBlock.vue
+++ b/packages/vue/markput/src/components/DraggableBlock.vue
@@ -221,6 +221,7 @@ function onMenuDuplicate() {