diff --git a/package.json b/package.json index c89ae946..ad2790d9 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "oxlint --fix", "oxfmt" ], - "*.{json,md,css,html}": [ + "*.{json,css,html}": [ "oxfmt" ] }, diff --git a/packages/common/core/index.ts b/packages/common/core/index.ts index 0718f56e..441a3ed6 100644 --- a/packages/common/core/index.ts +++ b/packages/common/core/index.ts @@ -85,7 +85,16 @@ 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, + BLOCK_SEPARATOR, + getAlwaysShowHandle, + 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.spec.ts b/packages/common/core/src/features/blocks/blockOperations.spec.ts new file mode 100644 index 00000000..e0399733 --- /dev/null +++ b/packages/common/core/src/features/blocks/blockOperations.spec.ts @@ -0,0 +1,73 @@ +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} +} + +const THREE_BLOCKS: Block[] = [makeBlock('0', 0, 1), makeBlock('3', 3, 4), makeBlock('6', 6, 7)] + +describe('addBlock', () => { + it('appends block separator when blocks is empty', () => { + expect(addBlock('A', [], 0)).toBe('A\n\n') + }) + + it('appends block separator when afterIndex is last block', () => { + expect(addBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB\n\nC\n\n') + }) + + it('inserts block separator after middle block', () => { + expect(addBlock('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('A\n\n\n\nB\n\nC') + }) + + it('inserts block separator after first block in two-block value', () => { + const blocks: Block[] = [makeBlock('0', 0, 1), makeBlock('3', 3, 4)] + expect(addBlock('A\n\nB', blocks, 0)).toBe('A\n\n\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', () => { + expect(deleteBlock('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('B\n\nC') + }) + + it('deletes middle block', () => { + expect(deleteBlock('A\n\nB\n\nC', THREE_BLOCKS, 1)).toBe('A\n\nC') + }) + + it('deletes last block', () => { + expect(deleteBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB') + }) + + it('deletes from a two-block value', () => { + const blocks: Block[] = [makeBlock('0', 0, 1), makeBlock('3', 3, 4)] + expect(deleteBlock('A\n\nB', blocks, 0)).toBe('B') + expect(deleteBlock('A\n\nB', blocks, 1)).toBe('A') + }) +}) + +describe('duplicateBlock', () => { + it('duplicates last block by appending', () => { + expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB\n\nC\n\nC') + }) + + it('duplicates first block into middle', () => { + expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('A\n\nA\n\nB\n\nC') + }) + + it('duplicates middle block', () => { + expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 1)).toBe('A\n\nB\n\nB\n\nC') + }) + + it('duplicates single block', () => { + const blocks: Block[] = [makeBlock('0', 0, 1)] + expect(duplicateBlock('A', blocks, 0)).toBe('A\n\nA') + }) +}) \ No newline at end of file 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..70ff99cf --- /dev/null +++ b/packages/common/core/src/features/blocks/blockOperations.ts @@ -0,0 +1,44 @@ +import {BLOCK_SEPARATOR} from './config' +import type {Block} from './splitTokensIntoBlocks' + +export function addBlock(value: string, blocks: Block[], afterIndex: number): string { + if (afterIndex >= blocks.length - 1) { + return value + BLOCK_SEPARATOR + } + + const insertPos = blocks[afterIndex + 1].startPos + return value.slice(0, insertPos) + BLOCK_SEPARATOR + 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) +} + +/** + * 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) + + if (index >= blocks.length - 1) { + return value + BLOCK_SEPARATOR + blockText + } + + const insertPos = blocks[index + 1].startPos + return value.slice(0, insertPos) + blockText + BLOCK_SEPARATOR + value.slice(insertPos) +} \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts b/packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts index dd93136d..d564b95e 100644 --- a/packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts +++ b/packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts @@ -24,16 +24,15 @@ function setupStore(value: string, markups: Markup[] = []) { describe('block reorder → getTokensByValue integration', () => { it('fix path: full re-parse + set tokens gives correct result after reorder', () => { - const original = 'aaa\nbbb\nccc' + const original = 'aaa\n\nbbb\n\nccc' const store = setupStore(original) const tokens = store.state.tokens.get() const blocks = splitTokensIntoBlocks(tokens) const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\naaa\nccc') + expect(reordered).toBe('bbb\n\naaa\n\nccc') - // Apply the fix: full re-parse, set tokens, sync previousValue const newTokens = parseWithParser(store, reordered) store.state.tokens.set(newTokens) store.state.previousValue.set(reordered) @@ -42,16 +41,15 @@ describe('block reorder → getTokensByValue integration', () => { expect(newTokens).not.toBe(tokens) const fullContent = newTokens.map(t => t.content).join('') - expect(fullContent).toBe('bbb\naaa\nccc') + expect(fullContent).toBe('bbb\n\naaa\n\nccc') - // Subsequent getTokensByValue should be stable (no gap → returns current tokens) const stable = getTokensByValue(store) - expect(stable.map(t => t.content).join('')).toBe('bbb\naaa\nccc') + expect(stable.map(t => t.content).join('')).toBe('bbb\n\naaa\n\nccc') }) it('fix path with parser: full re-parse gives correct tokens after reorder', () => { - const original = '# Heading\nParagraph' - const store = setupStore(original, ['# __nested__\n' as Markup]) + const original = '# Heading\n\nParagraph' + const store = setupStore(original, ['# __nested__\n\n' as Markup]) const tokens = store.state.tokens.get() const blocks = splitTokensIntoBlocks(tokens) @@ -59,27 +57,26 @@ describe('block reorder → getTokensByValue integration', () => { expect(blocks).toHaveLength(2) const reordered = reorderBlocks(original, blocks, 1, 0) - expect(reordered).toBe('Paragraph\n# Heading') + expect(reordered).toBe('Paragraph\n\n# Heading') - // Apply the fix const newTokens = parseWithParser(store, reordered) store.state.tokens.set(newTokens) store.state.previousValue.set(reordered) store.state.value.set(reordered) const fullContent = newTokens.map(t => t.content).join('') - expect(fullContent).toBe('Paragraph\n# Heading') + expect(fullContent).toBe('Paragraph\n\n# Heading') }) it('full re-parse produces correct blocks after reorder', () => { - const original = 'first\nsecond\nthird' + const original = 'first\n\nsecond\n\nthird' const store = setupStore(original) const tokens = store.state.tokens.get() const blocks = splitTokensIntoBlocks(tokens) const reordered = reorderBlocks(original, blocks, 2, 0) - expect(reordered).toBe('third\nfirst\nsecond') + expect(reordered).toBe('third\n\nfirst\n\nsecond') const newTokens = parseWithParser(store, reordered) const newBlocks = splitTokensIntoBlocks(newTokens) @@ -91,35 +88,32 @@ describe('block reorder → getTokensByValue integration', () => { }) it('direct token set + previousValue sync bypasses broken incremental parse', () => { - const original = 'aaa\nbbb\nccc' + const original = 'aaa\n\nbbb\n\nccc' const store = setupStore(original) const blocks = splitTokensIntoBlocks(store.state.tokens.get()) const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\naaa\nccc') + expect(reordered).toBe('bbb\n\naaa\n\nccc') - // This is the fix: full re-parse + set tokens + sync previousValue const newTokens = parseWithParser(store, reordered) store.state.tokens.set(newTokens) store.state.previousValue.set(reordered) store.state.value.set(reordered) - // Now getTokensByValue should see no gap and return current (correct) tokens const result = getTokensByValue(store) const resultContent = result.map(t => t.content).join('') - expect(resultContent).toBe('bbb\naaa\nccc') + expect(resultContent).toBe('bbb\n\naaa\n\nccc') }) it('without fix: incremental parse crashes on block reorder', () => { - const original = 'aaa\nbbb\nccc' + const original = 'aaa\n\nbbb\n\nccc' const store = setupStore(original) const blocks = splitTokensIntoBlocks(store.state.tokens.get()) const reordered = reorderBlocks(original, blocks, 0, 2) - // Only set value without updating tokens/previousValue — this is the broken path store.state.value.set(reordered) - expect(() => getTokensByValue(store)).toThrow() + expect(() => getTokensByValue(store)).toThrow(TypeError) }) }) \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/config.ts b/packages/common/core/src/features/blocks/config.ts new file mode 100644 index 00000000..7aa6862b --- /dev/null +++ b/packages/common/core/src/features/blocks/config.ts @@ -0,0 +1,5 @@ +export const BLOCK_SEPARATOR = '\n\n' + +export function getAlwaysShowHandle(block: boolean | {alwaysShowHandle: boolean}): boolean { + return typeof block === 'object' && !!block.alwaysShowHandle +} \ 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..9e5e1503 100644 --- a/packages/common/core/src/features/blocks/index.ts +++ b/packages/common/core/src/features/blocks/index.ts @@ -1,2 +1,4 @@ export {splitTokensIntoBlocks, type Block} from './splitTokensIntoBlocks' -export {reorderBlocks} from './reorderBlocks' \ No newline at end of file +export {reorderBlocks} from './reorderBlocks' +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/blocks/reorderBlocks.spec.ts b/packages/common/core/src/features/blocks/reorderBlocks.spec.ts index 99fde964..c99cc421 100644 --- a/packages/common/core/src/features/blocks/reorderBlocks.spec.ts +++ b/packages/common/core/src/features/blocks/reorderBlocks.spec.ts @@ -7,7 +7,7 @@ import type {Block} from './splitTokensIntoBlocks' import {splitTokensIntoBlocks} from './splitTokensIntoBlocks' function makeBlocks(...lines: string[]): {value: string; blocks: Block[]} { - const value = lines.join('\n') + const value = lines.join('\n\n') let pos = 0 const blocks: Block[] = lines.map(line => { const block: Block = { @@ -16,7 +16,7 @@ function makeBlocks(...lines: string[]): {value: string; blocks: Block[]} { startPos: pos, endPos: pos + line.length, } - pos += line.length + 1 + pos += line.length + 2 return block }) return {value, blocks} @@ -36,19 +36,19 @@ describe('reorderBlocks', () => { it('moves block forward', () => { const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') const result = reorderBlocks(value, blocks, 0, 2) - expect(result).toBe('bbb\naaa\nccc') + expect(result).toBe('bbb\n\naaa\n\nccc') }) it('moves block backward', () => { const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') const result = reorderBlocks(value, blocks, 2, 0) - expect(result).toBe('ccc\naaa\nbbb') + expect(result).toBe('ccc\n\naaa\n\nbbb') }) it('moves block to end', () => { const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') const result = reorderBlocks(value, blocks, 0, 3) - expect(result).toBe('bbb\nccc\naaa') + expect(result).toBe('bbb\n\nccc\n\naaa') }) it('returns same value for single block', () => { @@ -71,25 +71,25 @@ describe('reorderBlocks', () => { it('moves first block to last position', () => { const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc', 'ddd') const result = reorderBlocks(value, blocks, 0, 4) - expect(result).toBe('bbb\nccc\nddd\naaa') + expect(result).toBe('bbb\n\nccc\n\nddd\n\naaa') }) it('moves last block to first position', () => { const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc', 'ddd') const result = reorderBlocks(value, blocks, 3, 0) - expect(result).toBe('ddd\naaa\nbbb\nccc') + expect(result).toBe('ddd\n\naaa\n\nbbb\n\nccc') }) }) describe('reorderBlocks round-trip (reorder → re-parse → re-split)', () => { it('plain text: reordered value re-parses into correct blocks', () => { - const original = 'aaa\nbbb\nccc' + const original = 'aaa\n\nbbb\n\nccc' const parser = new Parser([]) const tokens = parser.parse(original) const blocks = splitTokensIntoBlocks(tokens) const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\naaa\nccc') + expect(reordered).toBe('bbb\n\naaa\n\nccc') const newTokens = parser.parse(reordered) const newBlocks = splitTokensIntoBlocks(newTokens) @@ -101,15 +101,15 @@ describe('reorderBlocks round-trip (reorder → re-parse → re-split)', () => { }) it('markdown: reordered value re-parses into correct blocks', () => { - const original = '# Heading\nParagraph text\n## Subheading' - const parser = new Parser(['# __nested__\n', '## __nested__\n']) + const original = '# Heading\n\nParagraph text\n\n## Subheading' + const parser = new Parser(['# __nested__\n\n', '## __nested__\n\n']) const tokens = parser.parse(original) const blocks = splitTokensIntoBlocks(tokens) expect(blocks).toHaveLength(3) const reordered = reorderBlocks(original, blocks, 2, 0) - expect(reordered).toBe('## Subheading\n# Heading\nParagraph text') + expect(reordered).toBe('## Subheading\n\n# Heading\n\nParagraph text') const newTokens = parser.parse(reordered) const newBlocks = splitTokensIntoBlocks(newTokens) @@ -119,7 +119,7 @@ describe('reorderBlocks round-trip (reorder → re-parse → re-split)', () => { it('full cycle: split → reorder → re-parse → re-split produces consistent blocks', () => { const lines = ['First line', 'Second line', 'Third line', 'Fourth line'] - const original = lines.join('\n') + const original = lines.join('\n\n') const parser = new Parser([]) const tokens1 = parser.parse(original) @@ -127,7 +127,7 @@ describe('reorderBlocks round-trip (reorder → re-parse → re-split)', () => { expect(blocks1).toHaveLength(4) const reordered = reorderBlocks(original, blocks1, 0, 4) - expect(reordered).toBe('Second line\nThird line\nFourth line\nFirst line') + expect(reordered).toBe('Second line\n\nThird line\n\nFourth line\n\nFirst line') const tokens2 = parser.parse(reordered) const blocks2 = splitTokensIntoBlocks(tokens2) @@ -139,38 +139,38 @@ describe('reorderBlocks round-trip (reorder → re-parse → re-split)', () => { expect(reordered2).toBe(original) }) - it('preserves single newline between adjacent blocks after reorder', () => { - const original = 'aaa\nbbb\nccc' + it('preserves double newline between adjacent blocks after reorder', () => { + const original = 'aaa\n\nbbb\n\nccc' const parser = new Parser([]) const tokens = parser.parse(original) const blocks = splitTokensIntoBlocks(tokens) const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\naaa\nccc') - expect(reordered.split('\n')).toHaveLength(3) + expect(reordered).toBe('bbb\n\naaa\n\nccc') + expect(reordered.split('\n\n')).toHaveLength(3) }) - it('does not preserve trailing newline from original after reorder', () => { - const original = 'aaa\nbbb\nccc\n' + it('trailing double newline creates empty block and is preserved after reorder', () => { + const original = 'aaa\n\nbbb\n\nccc\n\n' const parser = new Parser([]) const tokens = parser.parse(original) const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) - expect(blocks[2].endPos).toBe(11) + expect(blocks).toHaveLength(4) + expect(blocks[2].endPos).toBe(13) const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\naaa\nccc') + expect(reordered).toBe('bbb\n\naaa\n\nccc\n\n') }) it('handles unicode content correctly', () => { - const original = '你好\n世界\n🎉' + const original = '你好\n\n世界\n\n🎉' const parser = new Parser([]) const tokens = parser.parse(original) const blocks = splitTokensIntoBlocks(tokens) const reordered = reorderBlocks(original, blocks, 2, 0) - expect(reordered).toBe('🎉\n你好\n世界') + expect(reordered).toBe('🎉\n\n你好\n\n世界') const newTokens = parser.parse(reordered) const newBlocks = splitTokensIntoBlocks(newTokens) diff --git a/packages/common/core/src/features/blocks/reorderBlocks.ts b/packages/common/core/src/features/blocks/reorderBlocks.ts index 29e25202..1beab00f 100644 --- a/packages/common/core/src/features/blocks/reorderBlocks.ts +++ b/packages/common/core/src/features/blocks/reorderBlocks.ts @@ -1,3 +1,4 @@ +import {BLOCK_SEPARATOR} from './config' import type {Block} from './splitTokensIntoBlocks' interface OrderedBlock { @@ -44,14 +45,14 @@ function reassembleBlocks(orderedBlocks: OrderedBlock[]): string { const isLast = i === orderedBlocks.length - 1 let text = block.text - if (text.endsWith('\n')) { + while (text.endsWith('\n')) { text = text.slice(0, -1) } result.push(text) if (!isLast) { - result.push(block.separatorAfter || '\n') + result.push(block.separatorAfter || BLOCK_SEPARATOR) } } @@ -78,9 +79,9 @@ function redistributeSeparators(blocks: OrderedBlock[], originalBlocks: OrderedB if (Math.abs(currentOriginalIndex - nextOriginalIndex) === 1) { const earlierIndex = Math.min(currentOriginalIndex, nextOriginalIndex) const originalSeparator = originalBlocks[earlierIndex].separatorAfter - blocks[i].separatorAfter = originalSeparator.length > 0 ? originalSeparator : '\n' + blocks[i].separatorAfter = originalSeparator.length > 0 ? originalSeparator : BLOCK_SEPARATOR } else { - blocks[i].separatorAfter = '\n' + blocks[i].separatorAfter = BLOCK_SEPARATOR } } } \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts index ae2e3932..b13508d0 100644 --- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts +++ b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts @@ -39,19 +39,26 @@ describe('splitTokensIntoBlocks', () => { expect((blocks[0].tokens[0] as TextToken).content).toBe('hello world') }) - it('splits text by newlines into separate blocks', () => { + it('keeps single newlines as content within a block', () => { const tokens: Token[] = [text('line one\nline two\nline three', 0)] const blocks = splitTokensIntoBlocks(tokens) + expect(blocks).toHaveLength(1) + expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two\nline three') + }) + + it('splits text by double newlines (empty lines) into separate blocks', () => { + const tokens: Token[] = [text('line one\n\nline two\n\nline three', 0)] + const blocks = splitTokensIntoBlocks(tokens) expect(blocks).toHaveLength(3) expect((blocks[0].tokens[0] as TextToken).content).toBe('line one') expect((blocks[1].tokens[0] as TextToken).content).toBe('line two') expect((blocks[2].tokens[0] as TextToken).content).toBe('line three') }) - it('handles block-level marks ending with newline', () => { - const heading = mark('# Hello\n', 0, 'Hello') - heading.content = '# Hello\n' - const tokens: Token[] = [heading, text('paragraph text', 9)] + it('handles block-level marks ending with double newline', () => { + const heading = mark('# Hello\n\n', 0, 'Hello') + heading.content = '# Hello\n\n' + const tokens: Token[] = [heading, text('paragraph text', 10)] const blocks = splitTokensIntoBlocks(tokens) expect(blocks).toHaveLength(2) expect(blocks[0].tokens[0]).toBe(heading) @@ -65,27 +72,46 @@ describe('splitTokensIntoBlocks', () => { expect(blocks[0].tokens).toHaveLength(3) }) - it('assigns correct positions to blocks', () => { - const tokens: Token[] = [text('aaa\nbbb\nccc', 0)] + it('assigns correct positions to blocks with double newlines', () => { + const tokens: Token[] = [text('aaa\n\nbbb\n\nccc', 0)] const blocks = splitTokensIntoBlocks(tokens) expect(blocks[0].startPos).toBe(0) expect(blocks[0].endPos).toBe(3) - expect(blocks[1].startPos).toBe(4) - expect(blocks[1].endPos).toBe(7) - expect(blocks[2].startPos).toBe(8) - expect(blocks[2].endPos).toBe(11) + expect(blocks[1].startPos).toBe(5) + expect(blocks[1].endPos).toBe(8) + expect(blocks[2].startPos).toBe(10) + expect(blocks[2].endPos).toBe(13) }) - it('handles consecutive newlines (empty lines)', () => { - const tokens: Token[] = [text('aaa\n\nbbb', 0)] + it('handles consecutive double newlines as separate empty blocks', () => { + const tokens: Token[] = [text('aaa\n\n\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 double newline', () => { + const tokens: Token[] = [text('aaa\n\nbbb\n\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('single trailing newline stays as content in last block', () => { + const tokens: Token[] = [text('aaa\n\nbbb\n', 0)] + const blocks = splitTokensIntoBlocks(tokens) + expect(blocks).toHaveLength(2) + expect((blocks[0].tokens[0] as TextToken).content).toBe('aaa') + expect((blocks[1].tokens[0] as TextToken).content).toBe('bbb\n') }) it('generates unique block ids based on start position', () => { - const tokens: Token[] = [text('aaa\nbbb\nccc', 0)] + const tokens: Token[] = [text('aaa\n\nbbb\n\nccc', 0)] const blocks = splitTokensIntoBlocks(tokens) const ids = blocks.map(b => b.id) expect(new Set(ids).size).toBe(ids.length) @@ -97,14 +123,19 @@ describe('splitTokensIntoBlocks', () => { expect(blocks).toHaveLength(0) }) - it('handles text with only newlines', () => { - const tokens: Token[] = [text('\n\n\n', 0)] + it('handles text with only double newlines', () => { + const tokens: Token[] = [text('\n\n\n\n', 0)] const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(0) + // 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) + }) }) - it('handles \\r\\n line endings (Windows)', () => { - const tokens: Token[] = [text('line one\r\nline two\r\nline three', 0)] + it('handles \\r\\n\\r\\n line endings (Windows double newline)', () => { + const tokens: Token[] = [text('line one\r\n\r\nline two\r\n\r\nline three', 0)] const blocks = splitTokensIntoBlocks(tokens) expect(blocks).toHaveLength(3) expect((blocks[0].tokens[0] as TextToken).content).toBe('line one') @@ -112,20 +143,30 @@ describe('splitTokensIntoBlocks', () => { expect((blocks[2].tokens[0] as TextToken).content).toBe('line three') }) - it('handles mixed \\n and \\r\\n line endings', () => { - const tokens: Token[] = [text('line one\nline two\r\nline three', 0)] + it('handles single \\r\\n as content within block', () => { + const tokens: Token[] = [text('line one\r\nline two', 0)] const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) + expect(blocks).toHaveLength(1) + expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two') }) - it('handles standalone \\r (old Mac style)', () => { - const tokens: Token[] = [text('line one\rline two', 0)] + it('handles mixed single and double line endings', () => { + const tokens: Token[] = [text('line one\nline two\r\n\r\nline three', 0)] const blocks = splitTokensIntoBlocks(tokens) expect(blocks).toHaveLength(2) + expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two') + expect((blocks[1].tokens[0] as TextToken).content).toBe('line three') + }) + + it('handles standalone \\r as newline content', () => { + const tokens: Token[] = [text('line one\rline two', 0)] + const blocks = splitTokensIntoBlocks(tokens) + expect(blocks).toHaveLength(1) + expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two') }) - it('handles unicode and emoji content', () => { - const tokens: Token[] = [text('你好世界\n🎉 emoji\nØÆÅ', 0)] + it('handles unicode and emoji content with double newlines', () => { + const tokens: Token[] = [text('你好世界\n\n🎉 emoji\n\nØÆÅ', 0)] const blocks = splitTokensIntoBlocks(tokens) expect(blocks).toHaveLength(3) expect((blocks[0].tokens[0] as TextToken).content).toBe('你好世界') @@ -135,7 +176,7 @@ describe('splitTokensIntoBlocks', () => { it('handles very long text without performance issues', () => { const lines = Array(1000).fill('line') - const longText = lines.join('\n') + const longText = lines.join('\n\n') const tokens: Token[] = [text(longText, 0)] const start = performance.now() diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts index 2ae7d8da..1a86eb25 100644 --- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts +++ b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts @@ -1,4 +1,5 @@ import type {Token, TextToken} from '../parsing/ParserV2/types' +import {BLOCK_SEPARATOR} from './config' export interface Block { id: string @@ -25,24 +26,29 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { const blocks: Block[] = [] let currentTokens: Token[] = [] let blockStart = -1 + let blockStartFromText = false - const flushBlock = (endPos: number) => { - if (currentTokens.length === 0) return + const flushBlock = (endPos: number, canCreateEmpty = false) => { + const isEmpty = currentTokens.length === 0 + if (blockStart === -1 && isEmpty && !canCreateEmpty) return + 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) { if (token.type === 'mark') { - const endsWithNewline = token.content.endsWith('\n') + const endsWithBlockSeparator = token.content.endsWith(BLOCK_SEPARATOR) - if (endsWithNewline) { + if (endsWithBlockSeparator) { flushBlock(token.position.start) blocks.push({ @@ -51,6 +57,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) @@ -61,13 +69,15 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { if (token.type !== 'text') continue const textToken = token - const parts = splitTextByNewlines(textToken) + const parts = splitTextByBlockSeparator(textToken) for (let i = 0; i < parts.length; i++) { const part = parts[i] - if (part.isNewline) { - flushBlock(part.position.start) + if (part.isBlockSeparator) { + flushBlock(part.position.start, true) + blockStart = part.position.end + blockStartFromText = true continue } @@ -82,9 +92,9 @@ 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) { + flushBlock(lastPos === -1 ? 0 : lastPos, currentTokens.length > 0 || blockStartFromText) } return blocks @@ -93,10 +103,10 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { interface TextPart { content: string position: {start: number; end: number} - isNewline: boolean + isBlockSeparator: boolean } -function splitTextByNewlines(token: TextToken): TextPart[] { +function splitTextByBlockSeparator(token: TextToken): TextPart[] { const parts: TextPart[] = [] const {content, position} = token @@ -109,7 +119,7 @@ function splitTextByNewlines(token: TextToken): TextPart[] { parts.push({ content: text, position: {start: offset, end: offset + text.length}, - isNewline: false, + isBlockSeparator: false, }) offset += text.length chars.length = 0 @@ -120,31 +130,43 @@ function splitTextByNewlines(token: TextToken): TextPart[] { const char = content[i] if (char === '\n') { - flushText() - parts.push({ - content: '\n', - position: {start: offset, end: offset + 1}, - isNewline: true, - }) - offset += 1 - } else if (char === '\r') { if (i + 1 < content.length && content[i + 1] === '\n') { flushText() parts.push({ - content: '\n', + content: BLOCK_SEPARATOR, position: {start: offset, end: offset + 2}, - isNewline: true, + isBlockSeparator: true, }) offset += 2 i++ } else { - flushText() - parts.push({ - content: '\n', - position: {start: offset, end: offset + 1}, - isNewline: true, - }) - offset += 1 + chars.push(char) + offset++ + } + } else if (char === '\r') { + if (i + 1 < content.length && content[i + 1] === '\n') { + if ( + i + 2 < content.length && + content[i + 2] === '\r' && + i + 3 < content.length && + content[i + 3] === '\n' + ) { + flushText() + parts.push({ + content: BLOCK_SEPARATOR, + position: {start: offset, end: offset + 4}, + isBlockSeparator: true, + }) + offset += 4 + i += 3 + } else { + chars.push('\n') + offset += 2 + i++ + } + } else { + chars.push('\n') + offset++ } } else { chars.push(char) 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/editable/ContentEditableController.ts b/packages/common/core/src/features/editable/ContentEditableController.ts index c0260270..c77ea586 100644 --- a/packages/common/core/src/features/editable/ContentEditableController.ts +++ b/packages/common/core/src/features/editable/ContentEditableController.ts @@ -24,8 +24,12 @@ export class ContentEditableController { const readOnly = this.store.state.readOnly.get() const value = readOnly ? 'false' : 'true' const children = container.children + const isBlock = !!this.store.state.block.get() - for (let i = 0; i < children.length; i += 2) { + // In non-block mode, even-indexed children are text spans (odd are marks). + // In block mode, all children are DraggableBlock divs and need contentEditable. + const step = isBlock ? 1 : 2 + for (let i = 0; i < children.length; i += step) { ;(children[i] as HTMLElement).contentEditable = value } } diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts index 3751cbe5..d3f898b3 100644 --- a/packages/common/core/src/features/input/KeyDownController.ts +++ b/packages/common/core/src/features/input/KeyDownController.ts @@ -1,6 +1,11 @@ import type {NodeProxy} from '../../shared/classes/NodeProxy' import {KEYBOARD} from '../../shared/constants' +import {deleteBlock, mergeBlocks} from '../blocks/blockOperations' +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' @@ -23,9 +28,12 @@ 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) + this.#handleEnter(e) selectAllText(this.store, e) } @@ -57,8 +65,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 @@ -73,6 +84,7 @@ export class KeyDownController { if (focus.isSpan && focus.isCaretAtBeginning && focus.prev.target) { event.preventDefault() deleteMark('prev', this.store) + return } } @@ -80,10 +92,203 @@ export class KeyDownController { if (focus.isSpan && focus.isCaretAtEnd && focus.next.target) { event.preventDefault() deleteMark('next', this.store) + return } } } + + if (!isBlockMode) 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 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 === '') { + 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 + } + + // 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 + } + } } + + #handleEnter(event: KeyboardEvent) { + if (!this.store.state.block.get()) return + if (event.key !== KEYBOARD.ENTER) return + if (event.shiftKey) return + + const container = this.store.refs.container + if (!container) return + + const activeElement = document.activeElement as HTMLElement | null + if (!activeElement || !container.contains(activeElement)) return + + event.preventDefault() + + // Find which block (container child) contains the active element + const blockDivs = container.children + let blockIndex = -1 + for (let i = 0; i < blockDivs.length; i++) { + if (blockDivs[i] === activeElement || blockDivs[i].contains(activeElement)) { + blockIndex = i + break + } + } + 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 blockDiv = blockDivs[blockIndex] as HTMLElement + const value = this.store.state.value.get() ?? this.store.state.previousValue.get() ?? '' + + // 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 + this.store.applyValue(newValue) + + // Focus the new block after re-render + queueMicrotask(() => { + const newBlockDivs = container.children + const newBlockIndex = blockIndex + 1 + if (newBlockIndex < newBlockDivs.length) { + const newBlock = newBlockDivs[newBlockIndex] as HTMLElement + newBlock.focus() + Caret.trySetIndex(newBlock, 0) + } + }) + } + + #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`. + * + * 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, focusOffset} = selection + if (!focusNode) return block.endPos + + return getDomRawPos(focusNode, focusOffset, blockDiv, block) } export function handleBeforeInput(store: Store, event: InputEvent): void { @@ -100,6 +305,14 @@ 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. + // 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 @@ -214,4 +427,237 @@ 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() ?? '' + + 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() + 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)) + 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': + 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)) + 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. 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 { + // 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 + } + 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: 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) { + if (offset === 0) return markToken.position.start + const nestedLen = markToken.nested?.content.length ?? markToken.value.length + 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) + } + + // 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/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 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/features/parsing/ParserV2/core/MarkupDescriptor.ts b/packages/common/core/src/features/parsing/ParserV2/core/MarkupDescriptor.ts index 52008ff3..421087b8 100644 --- a/packages/common/core/src/features/parsing/ParserV2/core/MarkupDescriptor.ts +++ b/packages/common/core/src/features/parsing/ParserV2/core/MarkupDescriptor.ts @@ -188,18 +188,18 @@ function convertTwoValuePattern( const filteredGapTypes = gapTypes.filter(type => type !== GAP_TYPE.Value) return {segments: newSegments, gapTypes: filteredGapTypes} +} - function createDynamicDefinition( - beforeSegment: string, - afterSegment: string, - nextSegment?: string - ): [string, string, string] { - if (!nextSegment) return [beforeSegment, afterSegment, ''] +function createDynamicDefinition( + beforeSegment: string, + afterSegment: string, + nextSegment?: string +): [string, string, string] { + if (!nextSegment) return [beforeSegment, afterSegment, ''] - const firstChar = nextSegment.charAt(0) - const exclusion = - firstChar && !afterSegment.includes(firstChar) && !nextSegment.startsWith(beforeSegment) ? firstChar : '' + const firstChar = nextSegment.charAt(0) + const exclusion = + firstChar && !afterSegment.includes(firstChar) && !nextSegment.startsWith(beforeSegment) ? firstChar : '' - return [beforeSegment, afterSegment, exclusion] - } + return [beforeSegment, afterSegment, exclusion] } \ 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/common/core/src/features/store/Store.ts b/packages/common/core/src/features/store/Store.ts index 3f6e60f3..8207e995 100644 --- a/packages/common/core/src/features/store/Store.ts +++ b/packages/common/core/src/features/store/Store.ts @@ -8,6 +8,7 @@ import {FocusController} from '../focus' import {KeyDownController} from '../input' import {Lifecycle} from '../lifecycle' import {OverlayController} from '../overlay' +import {parseWithParser} from '../parsing' import type {Token} from '../parsing' import {TextSelectionController} from '../selection' @@ -71,22 +72,32 @@ export class Store { style: undefined, slots: undefined, slotProps: undefined, + block: false, }, options.createUseHook ) } + applyValue(newValue: string): void { + const onChange = this.state.onChange.get() + if (!onChange) return + const newTokens = parseWithParser(this, newValue) + this.state.tokens.set(newTokens) + this.state.previousValue.set(newValue) + onChange(newValue) + } + createHandler(): MarkputHandler { - const store = this + const {refs, nodes} = this return { get container() { - return store.refs.container + return refs.container }, get overlay() { - return store.refs.overlay + return refs.overlay }, focus() { - store.nodes.focus.head?.focus() + nodes.focus.head?.focus() }, } } diff --git a/packages/common/core/src/shared/types.ts b/packages/common/core/src/shared/types.ts index 60d8ebd7..bdc7f09a 100644 --- a/packages/common/core/src/shared/types.ts +++ b/packages/common/core/src/shared/types.ts @@ -65,6 +65,7 @@ export interface MarkputState { style: StyleProperties | undefined slots: CoreSlots | undefined slotProps: CoreSlotProps | undefined + block: boolean | {alwaysShowHandle: boolean} } export type OverlayMatch = { 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/common/core/styles.module.css b/packages/common/core/styles.module.css index 580c5805..09f77ddf 100644 --- a/packages/common/core/styles.module.css +++ b/packages/common/core/styles.module.css @@ -2,6 +2,39 @@ min-height: 1.4em; } +.Icon { + display: inline-block; + width: 14px; + height: 14px; + background-color: currentColor; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + flex-shrink: 0; +} + +.IconGrip { + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='5' cy='3' r='1.5'/%3E%3Ccircle cx='11' cy='3' r='1.5'/%3E%3Ccircle cx='5' cy='8' r='1.5'/%3E%3Ccircle cx='11' cy='8' r='1.5'/%3E%3Ccircle cx='5' cy='13' r='1.5'/%3E%3Ccircle cx='11' cy='13' r='1.5'/%3E%3C/svg%3E"); +} + +.IconAdd, +.IconDuplicate, +.IconTrash { + opacity: 0.75; +} + +.IconAdd { + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2a.75.75 0 0 1 .75.75v4.5h4.5a.75.75 0 0 1 0 1.5h-4.5v4.5a.75.75 0 0 1-1.5 0v-4.5h-4.5a.75.75 0 0 1 0-1.5h4.5v-4.5A.75.75 0 0 1 8 2Z'/%3E%3C/svg%3E"); +} + +.IconDuplicate { + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10 9C9.44772 9 9 9.44772 9 10V20C9 20.5523 9.44772 21 10 21H20C20.5523 21 21 20.5523 21 20V10C21 9.44772 20.5523 9 20 9H10ZM7 10C7 8.34315 8.34315 7 10 7H20C21.6569 7 23 8.34315 23 10V20C23 21.6569 21.6569 23 20 23H10C8.34315 23 7 21.6569 7 20V10Z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4 3C3.45228 3 3 3.45228 3 4V14C3 14.5477 3.45228 15 4 15C4.55228 15 5 15.4477 5 16C5 16.5523 4.55228 17 4 17C2.34772 17 1 15.6523 1 14V4C1 2.34772 2.34772 1 4 1H14C15.6523 1 17 2.34772 17 4C17 4.55228 16.5523 5 16 5C15.4477 5 15 4.55228 15 4C15 3.45228 14.5477 3 14 3H4Z'/%3E%3C/svg%3E"); +} + +.IconTrash { + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M7 5V4C7 3.17477 7.40255 2.43324 7.91789 1.91789C8.43324 1.40255 9.17477 1 10 1H14C14.8252 1 15.5668 1.40255 16.0821 1.91789C16.5975 2.43324 17 3.17477 17 4V5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H20V20C20 20.8252 19.5975 21.5668 19.0821 22.0821C18.5668 22.5975 17.8252 23 17 23H7C6.17477 23 5.43324 22.5975 4.91789 22.0821C4.40255 21.5668 4 20.8252 4 20V7H3C2.44772 7 2 6.55228 2 6C2 5.44772 2.44772 5 3 5H7ZM9 4C9 3.82523 9.09745 3.56676 9.33211 3.33211C9.56676 3.09745 9.82523 3 10 3H14C14.1748 3 14.4332 3.09745 14.6679 3.33211C14.9025 3.56676 15 3.82523 15 4V5H9V4ZM6 7V20C6 20.1748 6.09745 20.4332 6.33211 20.6679C6.56676 20.9025 6.82523 21 7 21H17C17.1748 21 17.4332 20.9025 17.6679 20.6679C17.9025 20.4332 18 20.1748 18 20V7H6Z'/%3E%3C/svg%3E"); +} + .Container span { outline: none; white-space: pre-wrap; diff --git a/packages/react/markput/src/components/BlockContainer.tsx b/packages/react/markput/src/components/BlockContainer.tsx index 18b4486d..848f4d4b 100644 --- a/packages/react/markput/src/components/BlockContainer.tsx +++ b/packages/react/markput/src/components/BlockContainer.tsx @@ -1,18 +1,152 @@ import { + cx, resolveSlot, resolveSlotProps, splitTokensIntoBlocks, reorderBlocks, - parseWithParser, + addBlock, + deleteBlock, + duplicateBlock, + getAlwaysShowHandle, 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' +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 + onDelete: () => void + onDuplicate: () => void + onClose: () => void +} + +const separatorStyle: CSSProperties = { + height: 1, + background: 'rgba(55, 53, 47, 0.09)', + margin: '4px 0', +} + +const BlockMenu = memo(({position, onAdd, 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' : 'rgba(55, 53, 47, 0.85)', + background: + hoveredItem === key + ? key === 'delete' + ? 'rgba(235, 87, 87, 0.06)' + : 'rgba(55, 53, 47, 0.06)' + : 'transparent', + transition: 'background 0.1s ease', + userSelect: 'none', + lineHeight: 1, + }) + + return ( +
+
setHoveredItem('add')} + onMouseLeave={() => setHoveredItem(null)} + onMouseDown={e => { + e.preventDefault() + onAdd() + onClose() + }} + > + + Add below +
+
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() @@ -21,48 +155,113 @@ export const BlockContainer = memo(() => { const className = store.state.className.use() const style = store.state.style.use() const readOnly = store.state.readOnly.use() + const block = store.state.block.use() + const alwaysShowHandle = getAlwaysShowHandle(block) const value = store.state.value.use() const onChange = store.state.onChange.use() 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]) - 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 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) - } + if (value == null || !onChange) return + const newValue = reorderBlocks(value, blocksRef.current, sourceIndex, targetIndex) + if (newValue !== value) store.applyValue(newValue) + }, + [store, value, onChange] + ) + + const handleAdd = useCallback( + (afterIndex: number) => { + 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 == null || !onChange) return + store.applyValue(deleteBlock(value, blocksRef.current, index)) + }, + [store, value, onChange] + ) + + const handleDuplicate = useCallback( + (index: number) => { + if (value == null || !onChange) return + store.applyValue(duplicateBlock(value, blocksRef.current, index)) }, [store, value, onChange] ) + 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 && ( + { + handleAdd(menuState.index) + closeMenu() + }} + onDelete={() => { + 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 3ce22077..18d7cd13 100644 --- a/packages/react/markput/src/components/DraggableBlock.tsx +++ b/packages/react/markput/src/components/DraggableBlock.tsx @@ -1,176 +1,185 @@ -import type {ReactNode, DragEvent, CSSProperties} from 'react' -import {memo, useCallback, useRef, useState} from 'react' +import type {ReactNode, DragEvent, CSSProperties, MouseEvent} from 'react' +import {Children, memo, useCallback, useRef, useState} from 'react' + +import styles from '@markput/core/styles.module.css' + +const iconGrip = `${styles.Icon} ${styles.IconGrip}` + +export interface MenuPosition { + top: number + left: number +} interface DraggableBlockProps { blockIndex: number children: ReactNode readOnly: boolean + alwaysShowHandle?: boolean onReorder: (sourceIndex: number, targetIndex: number) => void + onRequestMenu?: (index: number, rect: DOMRect) => 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, -} +type DropPosition = 'before' | 'after' | null -const GripIcon = memo(() => ( - - - - - - - - -)) +export const DraggableBlock = memo( + ({blockIndex, children, readOnly, alwaysShowHandle, onReorder, onRequestMenu}: DraggableBlockProps) => { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [dropPosition, setDropPosition] = useState(null) + const blockRef = useRef(null) + const gripRef = useRef(null) + + const handleMouseEnter = useCallback(() => setIsHovered(true), []) + const handleMouseLeave = useCallback(() => setIsHovered(false), []) + + const handleDragStart = useCallback( + (e: DragEvent) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', String(blockIndex)) + setIsDragging(true) + + if (blockRef.current) { + e.dataTransfer.setDragImage(blockRef.current, 0, 0) + } + }, + [blockIndex] + ) + + const handleDragEnd = useCallback(() => { + setIsDragging(false) + setDropPosition(null) + }, []) -GripIcon.displayName = 'GripIcon' + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' -type DropPosition = 'before' | 'after' | null + if (!blockRef.current) return -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) - - const handleMouseEnter = useCallback(() => setIsHovered(true), []) - const handleMouseLeave = useCallback(() => setIsHovered(false), []) - - const handleDragStart = useCallback( - (e: DragEvent) => { - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', String(blockIndex)) - setIsDragging(true) - - if (blockRef.current) { - e.dataTransfer.setDragImage(blockRef.current, 0, 0) - } - }, - [blockIndex] - ) - - const handleDragEnd = useCallback(() => { - setIsDragging(false) - setDropPosition(null) - }, []) - - const handleDragOver = useCallback((e: DragEvent) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - - if (!blockRef.current) return - - 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) - }, []) - - const handleDrop = useCallback( - (e: DragEvent) => { - e.preventDefault() - const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'), 10) - if (isNaN(sourceIndex)) return + const rect = blockRef.current.getBoundingClientRect() + const midY = rect.top + rect.height / 2 + setDropPosition(e.clientY < midY ? 'before' : 'after') + }, []) - const targetIndex = dropPosition === 'before' ? blockIndex : blockIndex + 1 + 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 handleStyle = readOnly - ? {...HANDLE_STYLES, display: 'none'} - : isDragging - ? HANDLE_DRAGGING_STYLES - : isHovered - ? HANDLE_VISIBLE_STYLES - : HANDLE_STYLES - - return ( -
- {dropPosition === 'before' &&
} - - - - {children} - - {dropPosition === 'after' &&
} -
- ) -}) + {dropPosition === 'before' &&
} + + {!readOnly && ( +
+ +
+ )} + + {Children.count(children) === 0 ?
: children} + + {dropPosition === 'after' &&
} +
+ ) + } +) DraggableBlock.displayName = 'DraggableBlock' \ No newline at end of file diff --git a/packages/react/markput/src/components/MarkedInput.tsx b/packages/react/markput/src/components/MarkedInput.tsx index 56630581..0a4d7595 100644 --- a/packages/react/markput/src/components/MarkedInput.tsx +++ b/packages/react/markput/src/components/MarkedInput.tsx @@ -70,8 +70,10 @@ export interface MarkedInputProps void /** Read-only mode */ readOnly?: boolean - /** Enable Notion-like draggable blocks with drag handles for reordering */ - block?: boolean + /** Enable Notion-like draggable blocks with drag handles for reordering. + * Pass an object to configure block behavior, e.g. `{ alwaysShowHandle: true }` for mobile. + */ + block?: boolean | {alwaysShowHandle: boolean} } export function MarkedInput( @@ -102,6 +104,7 @@ export function MarkedInput { 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/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Block/Block.spec.tsx new file mode 100644 index 00000000..0997810d --- /dev/null +++ b/packages/react/storybook/src/pages/Block/Block.spec.tsx @@ -0,0 +1,825 @@ +import {composeStories} from '@storybook/react-vite' +import {describe, expect, it} from 'vitest' +import {render} from 'vitest-browser-react' +import {page, userEvent} from 'vitest/browser' + +import {focusAtEnd, focusAtStart} from '../../shared/lib/focus' +import * as BlockStories from './Block.stories' + +const {BasicDraggable, MarkdownDocument, PlainTextBlocks, ReadOnlyDraggable} = composeStories(BlockStories) + +const GRIP_SELECTOR = 'button[aria-label="Drag to reorder or click for options"]' + +function getGrips(container: Element) { + return container.querySelectorAll(GRIP_SELECTOR) +} + +function getBlockDiv(grip: HTMLElement) { + return grip.closest('[data-testid="block"]') as HTMLElement +} + +function getEditableInBlock(blockDiv: 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 */
+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]
+	await userEvent.hover(getBlockDiv(grip))
+	await userEvent.click(grip)
+}
+
+describe('Feature: blocks', () => {
+	it('should render 5 blocks for BasicDraggable', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(5)
+	})
+
+	it('should render 6 blocks for MarkdownDocument', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(6)
+	})
+
+	it('should render 5 blocks for PlainTextBlocks', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(5)
+	})
+
+	it('should render no grip buttons in read-only mode', async () => {
+		const {container} = await render()
+		expect(getGrips(container)).toHaveLength(0)
+	})
+
+	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('menu', () => {
+		it('should open with Add below, Duplicate, Delete options', async () => {
+			const {container} = await render()
+			await openMenuForGrip(container, 0)
+
+			await expect.element(page.getByText('Add below')).toBeInTheDocument()
+			await expect.element(page.getByText('Duplicate')).toBeInTheDocument()
+			await expect.element(page.getByText('Delete')).toBeInTheDocument()
+		})
+
+		it('should close on Escape', async () => {
+			const {container} = await render()
+			await openMenuForGrip(container, 0)
+			await expect.element(page.getByText('Add below')).toBeInTheDocument()
+
+			await userEvent.keyboard('{Escape}')
+			await expect.element(page.getByText('Add below')).not.toBeInTheDocument()
+		})
+
+		it('should close when clicking outside', async () => {
+			const {container} = await render()
+			await openMenuForGrip(container, 0)
+			await expect.element(page.getByText('Add below')).toBeInTheDocument()
+
+			await userEvent.click(container.firstElementChild!)
+			await expect.element(page.getByText('Add below')).not.toBeInTheDocument()
+		})
+	})
+
+	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())
+
+			expect(getGrips(container)).toHaveLength(6)
+		})
+
+		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())
+
+			expect(getGrips(container)).toHaveLength(6)
+		})
+
+		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)
+			expect(raw).toContain('First block of plain text\n\n\n\nSecond block of plain text')
+		})
+
+		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())
+
+			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 ''
+			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())
+
+			expect(getGrips(container)).toHaveLength(4)
+		})
+
+		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)
+			expect(getRawValue(container)).toContain('Second block of plain text')
+		})
+
+		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)
+			expect(getRawValue(container)).toContain('Fourth block of plain text')
+			expect(getRawValue(container)).not.toContain('Fifth block of plain text')
+		})
+
+		it('should result in empty value when deleting the last remaining block', async () => {
+			const {container} = await render()
+
+			// 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())
+
+			expect(getGrips(container)).toHaveLength(6)
+		})
+
+		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 matches = getRawValue(container).match(/First block of plain text/g)
+			expect(matches).toHaveLength(2)
+		})
+
+		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())
+
+			expect(getGrips(container)).toHaveLength(6)
+		})
+	})
+
+	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)
+
+			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+			await focusAtEnd(editable)
+			await userEvent.keyboard('{Enter}')
+
+			expect(getGrips(container)).toHaveLength(6)
+		})
+
+		it('should preserve all block content after pressing Enter', async () => {
+			const {container} = await render()
+			const originalValue = getRawValue(container)
+
+			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+			await focusAtEnd(editable)
+			await userEvent.keyboard('{Enter}')
+
+			const newValue = getRawValue(container)
+			expect(newValue).not.toBe(originalValue)
+			expect(newValue).toContain('First block of plain text')
+			expect(newValue).toContain('Fifth block of plain text')
+		})
+
+		it('should not create a new block when pressing Shift+Enter', async () => {
+			const {container} = await render()
+
+			const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0]))
+			await focusAtEnd(editable)
+			await userEvent.keyboard('{Shift>}{Enter}{/Shift}')
+
+			expect(getGrips(container)).toHaveLength(5)
+		})
+	})
+
+	describe('drag & drop', () => {
+		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 focusAtStart(editable)
+		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 () => {
+		const {container} = await render()
+		const original = getRawValue(container)
+
+		await openMenuForGrip(container, 0)
+		await userEvent.click(page.getByText('Add below').element())
+		expect(getGrips(container)).toHaveLength(6)
+
+		await openMenuForGrip(container, 1)
+		await userEvent.click(page.getByText('Delete').element())
+		expect(getGrips(container)).toHaveLength(5)
+
+		expect(getRawValue(container)).toBe(original)
+	})
+
+	it('should restore original value after duplicate then delete', async () => {
+		const {container} = await render()
+		const original = getRawValue(container)
+
+		await openMenuForGrip(container, 0)
+		await userEvent.click(page.getByText('Duplicate').element())
+		expect(getGrips(container)).toHaveLength(6)
+
+		await openMenuForGrip(container, 1)
+		await userEvent.click(page.getByText('Delete').element())
+		expect(getGrips(container)).toHaveLength(5)
+
+		expect(getRawValue(container)).toBe(original)
+	})
+})
+
+/** 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 () => {
+			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('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')
+		})
+
+		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', () => {
+		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
diff --git a/packages/react/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.tsx b/packages/react/storybook/src/pages/Block/Block.stories.tsx
similarity index 67%
rename from packages/react/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.tsx
rename to packages/react/storybook/src/pages/Block/Block.stories.tsx
index fe9f67f3..326e53ff 100644
--- a/packages/react/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.tsx
+++ b/packages/react/storybook/src/pages/Block/Block.stories.tsx
@@ -4,17 +4,18 @@ import type {ReactNode} from 'react'
 import {useState} from 'react'
 
 import {Text} from '../../shared/components/Text'
+import {COMPLEX_MARKDOWN} from '../../shared/lib/sampleTexts'
 import {markdownOptions} from '../Nested/MarkdownOptions'
 
 export default {
-	title: 'MarkedInput/DraggableBlocks',
+	title: 'MarkedInput/Block',
 	tags: ['autodocs'],
 	component: MarkedInput,
 	parameters: {
 		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).',
 			},
 		},
 	},
@@ -34,18 +35,20 @@ const MarkdownMark = ({
 
 export const BasicDraggable: Story = {
 	render: () => {
-		const [value, setValue] = useState(
-			`# Welcome to Draggable Blocks
+		const [value, setValue] = useState(`# Welcome to Draggable Blocks
+
 This is the first paragraph. Hover to see the drag handle on the left.
+
 This is the second paragraph. Try dragging it above the first one!
+
 ## Features
+
 - Drag handles appear on hover
 - Drop indicators show where the block will land
-- Blocks reorder by manipulating the underlying string`
-		)
+- Blocks reorder by manipulating the underlying string`)
 
 		return (
-			
+
{ - const [value, setValue] = useState(`# Project Roadmap -## Phase 1: Foundation -Build the core parsing engine with support for nested markup patterns. -## Phase 2: Rich Text -Add markdown-style formatting: **bold**, *italic*, \`code\`, and ~~strikethrough~~. -## Phase 3: Collaboration -Implement real-time collaboration with conflict resolution. -## Phase 4: Extensions -Create a plugin system for custom markup patterns.`) + const [value, setValue] = useState(COMPLEX_MARKDOWN) return ( -
- +
+
) @@ -91,14 +79,19 @@ Create a plugin system for custom markup patterns.`) export const ReadOnlyDraggable: Story = { render: () => { const value = `# Read-Only Mode + Drag handles are hidden in read-only mode. + ## Section A + Content cannot be reordered. + ## Section B + This is static content.` return ( -
+
{ + const [value, setValue] = useState(`# Always-visible handles + +Drag handles are always visible — ideal for touch devices. + +## Try it + +Tap the grip icon to open the block menu.`) + + return ( +
+ + +
+ ) + }, +} + export const PlainTextBlocks: Story = { render: () => { - const [value, setValue] = useState( - `First block of plain text + const [value, setValue] = useState(`First block of plain text + Second block of plain text + Third block of plain text + Fourth block of plain text -Fifth block of plain text` - ) + +Fifth block of plain text`) return ( -
+
= { h1: { - markup: '# __nested__\n', + markup: '# __nested__\n\n', style: { display: 'block', fontSize: '2em', @@ -23,7 +23,7 @@ export const defaultMarkdownTheme: Record = { }, }, h2: { - markup: '## __nested__\n', + markup: '## __nested__\n\n', style: { display: 'block', fontSize: '1.5em', @@ -32,7 +32,7 @@ export const defaultMarkdownTheme: Record = { }, }, h3: { - markup: '### __nested__\n', + markup: '### __nested__\n\n', style: { display: 'block', fontSize: '1.17em', @@ -41,7 +41,7 @@ export const defaultMarkdownTheme: Record = { }, }, list: { - markup: '- __nested__\n', + markup: '- __nested__\n\n', style: { display: 'block', paddingLeft: '1em', diff --git a/packages/react/storybook/src/pages/Nested/Nested.stories.tsx b/packages/react/storybook/src/pages/Nested/Nested.stories.tsx index 35e1ca20..aab847d5 100644 --- a/packages/react/storybook/src/pages/Nested/Nested.stories.tsx +++ b/packages/react/storybook/src/pages/Nested/Nested.stories.tsx @@ -6,6 +6,7 @@ import {useState} from 'react' import {useTab} from '../../shared/components/Tabs' import {Text} from '../../shared/components/Text' +import {COMPLEX_MARKDOWN} from '../../shared/lib/sampleTexts' import {markdownOptions as MarkdownOptions} from './MarkdownOptions' export default { @@ -301,30 +302,7 @@ const MarkdownMark = ({ export const ComplexMarkdown: Story = { render: () => { - const [value, setValue] = useState(`# Welcome to **Marked Input** - -This is a *powerful* library for parsing **rich text** with *markdown* formatting. -You can use \`inline code\` snippets like \`const parser = new ParserV2()\` in your text. - -## Features - -- **Bold text** with **strong emphasis** -- *Italic text* and *emphasis* support -- \`Code snippets\` and \`code blocks\` -- ~~Strikethrough~~ for deleted content -- Links like [GitHub](https://github.com) - -## Example - -Here's how to use it: - -\`\`\`javascript -const parser = new ParserV2(['**__value__**', '*__value__*']) -const result = parser.parse('Hello **world**!') -\`\`\` - -Visit our [documentation](https://docs.example.com) for more details. -~~This feature is deprecated~~ and will be removed in v3.0.`) + const [value, setValue] = useState(COMPLEX_MARKDOWN) const {Tab, activeTab} = useTab([ {value: 'preview', label: 'Preview'}, {value: 'write', label: 'Write'}, diff --git a/packages/react/storybook/src/shared/lib/sampleTexts.ts b/packages/react/storybook/src/shared/lib/sampleTexts.ts new file mode 100644 index 00000000..1a7afb40 --- /dev/null +++ b/packages/react/storybook/src/shared/lib/sampleTexts.ts @@ -0,0 +1,24 @@ +export const COMPLEX_MARKDOWN = `# Welcome to **Marked Input** + +This is a *powerful* library for parsing **rich text** with *markdown* formatting. +You can use \`inline code\` snippets like \`const parser = new ParserV2()\` in your text. + +## Features + +- **Bold text** with **strong emphasis** +- *Italic text* and *emphasis* support +- \`Code snippets\` and \`code blocks\` +- ~~Strikethrough~~ for deleted content +- Links like [GitHub](https://github.com) + +## Example + +Here's how to use it: + +\`\`\`javascript +const parser = new ParserV2(['**__value__**', '*__value__*']) +const result = parser.parse('Hello **world**!') +\`\`\` + +Visit our [documentation](https://docs.example.com) for more details. +~~This feature is deprecated~~ and will be removed in v3.0.` \ No newline at end of file diff --git a/packages/vue/markput/src/components/BlockContainer.vue b/packages/vue/markput/src/components/BlockContainer.vue index 591d4362..a8457ce3 100644 --- a/packages/vue/markput/src/components/BlockContainer.vue +++ b/packages/vue/markput/src/components/BlockContainer.vue @@ -1,6 +1,14 @@ @@ -49,7 +69,11 @@ function handleReorder(sourceIndex: number, targetIndex: number) { :key="block.id" :block-index="index" :read-only="readOnly" + :always-show-handle="alwaysShowHandle" @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..db675d9e 100644 --- a/packages/vue/markput/src/components/DraggableBlock.vue +++ b/packages/vue/markput/src/components/DraggableBlock.vue @@ -1,33 +1,127 @@ - diff --git a/packages/vue/markput/src/components/MarkedInput.vue b/packages/vue/markput/src/components/MarkedInput.vue index 025d8882..f000f6df 100644 --- a/packages/vue/markput/src/components/MarkedInput.vue +++ b/packages/vue/markput/src/components/MarkedInput.vue @@ -42,6 +42,7 @@ function syncProps() { defaultValue: props.defaultValue, onChange: (v: string) => emit('change', v), readOnly: props.readOnly, + block: props.block, options: props.options, showOverlayOn: props.showOverlayOn, Mark: props.Mark, diff --git a/packages/vue/markput/src/types.ts b/packages/vue/markput/src/types.ts index df98e5cb..6fcfccff 100644 --- a/packages/vue/markput/src/types.ts +++ b/packages/vue/markput/src/types.ts @@ -32,8 +32,10 @@ export interface MarkedInputProps(GRIP_SELECTOR) +} + +function getBlockDiv(grip: HTMLElement) { + return grip.closest('[data-testid="block"]') as HTMLElement +} + +function getEditableInBlock(blockDiv: HTMLElement) { + return (blockDiv.querySelector('[contenteditable="true"]') ?? blockDiv) as HTMLElement +} + +function getBlocks(container: Element) { + return Array.from(container.querySelectorAll('[data-testid="block"]')) +} + +function getRawValue(container: Element) { + return container.querySelector('pre')!.textContent! +} + +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() + + grip.dispatchEvent(new DragEvent('dragstart', {bubbles: true, cancelable: true, dataTransfer: dt})) + + const rect = targetBlock.getBoundingClientRect() + targetBlock.dispatchEvent( + new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientY: position === 'before' ? rect.top + 1 : rect.bottom - 1, + }) + ) + + await new Promise(r => setTimeout(r, 50)) + + targetBlock.dispatchEvent(new DragEvent('drop', {bubbles: true, cancelable: true, dataTransfer: dt})) + grip.dispatchEvent(new DragEvent('dragend', {bubbles: true, cancelable: true})) + + 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] + await userEvent.hover(getBlockDiv(grip)) + await userEvent.click(grip) +} + +describe('Feature: blocks', () => { + it('should render 6 blocks for BasicDraggable', async () => { + const {container} = await render(BasicDraggable) + expect(getGrips(container)).toHaveLength(6) + }) + + it('should render 10 blocks for MarkdownDocument', async () => { + const {container} = await render(MarkdownDocument) + expect(getGrips(container)).toHaveLength(10) + }) + + it('should render 5 blocks for PlainTextBlocks', async () => { + const {container} = await render(PlainTextBlocks) + expect(getGrips(container)).toHaveLength(5) + }) + + it('should render no grip buttons in read-only mode', async () => { + const {container} = await render(ReadOnlyDraggable) + expect(getGrips(container)).toHaveLength(0) + }) + + it('should render content in read-only mode', async () => { + await render(ReadOnlyDraggable) + 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('menu', () => { + it('should open with Add below, Duplicate, Delete options', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 0) + + await expect.element(page.getByText('Add below')).toBeInTheDocument() + await expect.element(page.getByText('Duplicate')).toBeInTheDocument() + await expect.element(page.getByText('Delete')).toBeInTheDocument() + }) + + it('should close on Escape', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 0) + await expect.element(page.getByText('Add below')).toBeInTheDocument() + + await userEvent.keyboard('{Escape}') + await expect.element(page.getByText('Add below')).not.toBeInTheDocument() + }) + + it('should close when clicking outside', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 0) + await expect.element(page.getByText('Add below')).toBeInTheDocument() + + await userEvent.click(container.firstElementChild!) + await expect.element(page.getByText('Add below')).not.toBeInTheDocument() + }) + }) + + describe('add block', () => { + it('should increase block count by 1 when adding below first block', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Add below').element()) + + expect(getGrips(container)).toHaveLength(6) + }) + + it('should increase block count by 1 when adding below middle block', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 2) + await userEvent.click(page.getByText('Add below').element()) + + expect(getGrips(container)).toHaveLength(6) + }) + + it('should increase block count by 1 when adding below last block', async () => { + const {container} = await render(PlainTextBlocks) + 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(PlainTextBlocks) + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Add below').element()) + + const raw = getRawValue(container) + expect(raw).toContain('First block of plain text\n\n\n\nSecond block of plain text') + }) + + it('should not create a trailing separator when adding below last block', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 4) + await userEvent.click(page.getByText('Add below').element()) + + const raw = getRawValue(container) + expect(raw.endsWith('\n\n\n\n')).toBe(false) + }) + + it('should result in a single empty block when all blocks are deleted', async () => { + const {container} = await render(PlainTextBlocks) + + // 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()) + + expect(getRawValue(container)).toBe('') + }) + }) + + describe('delete block', () => { + it('should decrease count by 1 when deleting middle block', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 2) + await userEvent.click(page.getByText('Delete').element()) + + expect(getGrips(container)).toHaveLength(4) + }) + + it('should preserve remaining content when deleting first block', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Delete').element()) + + expect(getGrips(container)).toHaveLength(4) + expect(getRawValue(container)).toContain('Second block of plain text') + }) + + it('should decrease count by 1 when deleting last block', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 4) + await userEvent.click(page.getByText('Delete').element()) + + expect(getGrips(container)).toHaveLength(4) + expect(getRawValue(container)).toContain('Fourth block of plain text') + expect(getRawValue(container)).not.toContain('Fifth block of plain text') + }) + + it('should result in empty value when deleting the last remaining block', async () => { + const {container} = await render(PlainTextBlocks) + + // 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(PlainTextBlocks) + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Duplicate').element()) + + expect(getGrips(container)).toHaveLength(6) + }) + + it('should create a copy with the same text content', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Duplicate').element()) + + const matches = getRawValue(container).match(/First block of plain text/g) + expect(matches).toHaveLength(2) + }) + + it('should increase count by 1 when duplicating last block', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 4) + await userEvent.click(page.getByText('Duplicate').element()) + + expect(getGrips(container)).toHaveLength(6) + }) + }) + + describe('enter key', () => { + it('should create a new block when pressing Enter at end of block', async () => { + const {container} = await render(PlainTextBlocks) + expect(getGrips(container)).toHaveLength(5) + + const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) + await focusAtEnd(editable) + await userEvent.keyboard('{Enter}') + + expect(getGrips(container)).toHaveLength(6) + }) + + it('should preserve all block content after pressing Enter', async () => { + const {container} = await render(PlainTextBlocks) + const originalValue = getRawValue(container) + + const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) + await focusAtEnd(editable) + await userEvent.keyboard('{Enter}') + + const newValue = getRawValue(container) + expect(newValue).not.toBe(originalValue) + expect(newValue).toContain('First block of plain text') + expect(newValue).toContain('Fifth block of plain text') + }) + + it('should not create a new block when pressing Shift+Enter', async () => { + const {container} = await render(PlainTextBlocks) + + const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) + await focusAtEnd(editable) + await userEvent.keyboard('{Shift>}{Enter}{/Shift}') + + expect(getGrips(container)).toHaveLength(5) + }) + }) + + describe('drag & drop', () => { + it('should reorder blocks when dragging block 0 after block 2', async () => { + const {container} = await render(PlainTextBlocks) + + 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(PlainTextBlocks) + 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(PlainTextBlocks) + + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Add below').element()) + expect(getGrips(container)).toHaveLength(6) + + 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(PlainTextBlocks) + const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) + await focusAtEnd(editable) + await userEvent.keyboard('{Backspace}') + + expect(getGrips(container)).toHaveLength(5) + }) + }) + + it('should focus a block after Add below', async () => { + const {container} = await render(PlainTextBlocks) + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Add below').element()) + + const activeEl = document.activeElement as HTMLElement + expect(activeEl?.closest('[data-testid="block"]')).toBeTruthy() + }) + + it('should split block at caret when pressing Enter at the beginning', async () => { + const {container} = await render(PlainTextBlocks) + const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) + await focusAtStart(editable) + await userEvent.keyboard('{Enter}') + + expect(getGrips(container)).toHaveLength(6) + expect(getRawValue(container)).toContain('First block of plain text') + }) + + it('should restore original value after add then delete', async () => { + const {container} = await render(PlainTextBlocks) + const original = getRawValue(container) + + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Add below').element()) + expect(getGrips(container)).toHaveLength(6) + + await openMenuForGrip(container, 1) + await userEvent.click(page.getByText('Delete').element()) + expect(getGrips(container)).toHaveLength(5) + + expect(getRawValue(container)).toBe(original) + }) + + it('should restore original value after duplicate then delete', async () => { + const {container} = await render(PlainTextBlocks) + const original = getRawValue(container) + + await openMenuForGrip(container, 0) + await userEvent.click(page.getByText('Duplicate').element()) + expect(getGrips(container)).toHaveLength(6) + + await openMenuForGrip(container, 1) + await userEvent.click(page.getByText('Delete').element()) + expect(getGrips(container)).toHaveLength(5) + + expect(getRawValue(container)).toBe(original) + }) +}) + +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], + }) + ) +} + +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 () => { + const {container} = await render(PlainTextBlocks) + 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(PlainTextBlocks) + const blocks = getBlocks(container) + + await focusAtEnd(getEditableInBlock(blocks[1])) + await userEvent.keyboard('{ArrowLeft}') + + expect(document.activeElement).toBe(blocks[1]) + }) + + it('should not cross block boundary from the first block', async () => { + const {container} = await render(PlainTextBlocks) + 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(PlainTextBlocks) + 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(PlainTextBlocks) + const blocks = getBlocks(container) + + await focusAtStart(getEditableInBlock(blocks[0])) + await userEvent.keyboard('{ArrowRight}') + + expect(document.activeElement).toBe(blocks[0]) + }) + + it('should not cross block boundary from the last block', async () => { + const {container} = await render(PlainTextBlocks) + const blocks = getBlocks(container) + const last = blocks[blocks.length - 1] + + await focusAtEnd(getEditableInBlock(last)) + await userEvent.keyboard('{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(PlainTextBlocks) + 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(PlainTextBlocks) + 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(PlainTextBlocks) + 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(PlainTextBlocks) + 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(PlainTextBlocks) + 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(PlainTextBlocks) + + 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(PlainTextBlocks) + 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(PlainTextBlocks) + expect(getBlocks(container)).toHaveLength(5) + + await focusAtStart(getEditableInBlock(getBlocks(container)[1])) + await userEvent.keyboard('{Backspace}') + + 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(PlainTextBlocks) + 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(PlainTextBlocks) + + 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(PlainTextBlocks) + const currentBlock = getBlocks(container)[0] + + await focusAtEnd(getEditableInBlock(currentBlock)) + await userEvent.keyboard('{Delete}') + + expect(document.activeElement).toBe(currentBlock) + }) + + it('should not merge when Delete pressed at end of last block', async () => { + const {container} = await render(PlainTextBlocks) + const blocks = getBlocks(container) + const last = blocks[blocks.length - 1] + + await focusAtEnd(getEditableInBlock(last)) + await userEvent.keyboard('{Delete}') + + expect(getBlocks(container)).toHaveLength(5) + }) + }) + + describe('typing in blocks', () => { + it('should update raw value when typing a character at end of block', async () => { + const {container} = await render(PlainTextBlocks) + 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(PlainTextBlocks) + await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) + await userEvent.keyboard('{Backspace}') + + 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', async () => { + const {container} = await render(PlainTextBlocks) + const blocks = getBlocks(container) + + getEditableInBlock(blocks[1]).focus() + await userEvent.keyboard('{Control>}a{/Control}') + await userEvent.keyboard('X') + + 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', async () => { + const {container} = await render(MarkdownDocument) + const blocks = getBlocks(container) + await focusAtEnd(getEditableInBlock(blocks[0])) + await userEvent.keyboard('!') + + 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', async () => { + const {container} = await render(MarkdownDocument) + const blocks = getBlocks(container) + await focusAtStart(blocks[0]) + await userEvent.keyboard('{ArrowRight}{ArrowRight}') + dispatchInsertText(blocks[0], 'X') + await new Promise(r => setTimeout(r, 50)) + + const block0Raw = getRawValue(container).split('\n\n')[0] + expect(block0Raw).toBe('# WeXlcome to **Marked Input**') + }) + }) + + describe('paste in blocks', () => { + it('should update raw value when pasting text at end of a plain text block', async () => { + const {container} = await render(PlainTextBlocks) + 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(PlainTextBlocks) + 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(MarkdownDocument) + const blocks = getBlocks(container) + await focusAtEnd(getEditableInBlock(blocks[0])) + dispatchPaste(getEditableInBlock(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', () => { + it('should increase block count by 1', async () => { + const {container} = await render(PlainTextBlocks) + + 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(PlainTextBlocks) + + 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}') + + 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(PlainTextBlocks) + + 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}') + + 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(MarkdownDocument) + const blocks = getBlocks(container) + await focusAtEnd(blocks[0]) + await userEvent.keyboard('{Enter}') + + const raw = getRawValue(container) + expect(raw).toContain('**Marked Input**\n\n') + }) + }) +}) \ No newline at end of file diff --git a/packages/vue/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.ts b/packages/vue/storybook/src/pages/Block/Block.stories.ts similarity index 52% rename from packages/vue/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.ts rename to packages/vue/storybook/src/pages/Block/Block.stories.ts index aadc1bd7..e2df0762 100644 --- a/packages/vue/storybook/src/pages/DraggableBlocks/DraggableBlocks.stories.ts +++ b/packages/vue/storybook/src/pages/Block/Block.stories.ts @@ -6,14 +6,14 @@ import {defineComponent, h, ref} from 'vue' import Text from '../../shared/components/Text.vue' export default { - title: 'MarkedInput/DraggableBlocks', + title: 'MarkedInput/Block', tags: ['autodocs'], component: MarkedInput, parameters: { 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).', }, }, }, @@ -33,59 +33,33 @@ const MarkdownMark = defineComponent({ }, }) +const h1Style = {display: 'block', fontSize: '2em', fontWeight: 'bold', margin: '0.5em 0'} + const markdownOptions: Option[] = [ { - markup: '# __nested__\n' as Markup, - mark: (p: MarkProps) => ({ - ...p, - style: {display: 'block', fontSize: '2em', fontWeight: 'bold', margin: '0.5em 0'}, - }), + markup: '# __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, style: h1Style}), }, - { - markup: '## __nested__\n' as Markup, - mark: (p: MarkProps) => ({ - ...p, - style: {display: 'block', fontSize: '1.5em', fontWeight: 'bold', margin: '0.4em 0'}, - }), - }, - { - markup: '### __nested__\n' as Markup, - mark: (p: MarkProps) => ({ - ...p, - style: {display: 'block', fontSize: '1.17em', fontWeight: 'bold', margin: '0.83em 0'}, - }), - }, - { - markup: '- __nested__\n' as Markup, - mark: (p: MarkProps) => ({...p, style: {display: 'block', paddingLeft: '1em'}}), - }, - {markup: '**__nested__**' as Markup, mark: (p: MarkProps) => ({...p, style: {fontWeight: 'bold'}})}, - {markup: '*__nested__*' as Markup, mark: (p: MarkProps) => ({...p, style: {fontStyle: 'italic'}})}, - { - markup: '`__value__`' as Markup, - mark: (p: MarkProps) => ({ - ...p, - style: { - backgroundColor: '#f6f8fa', - padding: '2px 6px', - borderRadius: '3px', - fontFamily: 'monospace', - fontSize: '0.9em', - }, - }), - }, -] +] as 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 = { render: () => defineComponent({ setup() { - const value = ref( - `# Welcome to Draggable Blocks\nThis is the first paragraph. Hover to see the drag handle on the left.\nThis is the second paragraph. Try dragging it above the first one!\n## Features\n- Drag handles appear on hover\n- Drop indicators show where the block will land\n- Blocks reorder by manipulating the underlying string` - ) + const value = ref(`# Welcome to Draggable Blocks + +This is the first paragraph. Hover to see the drag handle on the left. + +This is the second paragraph. Try dragging it above the first one! + +## Features + +- Drag handles appear on hover +- Drop indicators show where the block will land +- Blocks reorder by manipulating the underlying string`) return () => h('div', {style: containerStyle}, [ h(MarkedInput, { @@ -108,9 +82,30 @@ export const MarkdownDocument: Story = { render: () => defineComponent({ setup() { - const value = ref( - `# Project Roadmap\n## Phase 1: Foundation\nBuild the core parsing engine with support for nested markup patterns.\n## Phase 2: Rich Text\nAdd markdown-style formatting: **bold**, *italic*, \`code\`.\n## Phase 3: Collaboration\nImplement real-time collaboration with conflict resolution.\n## Phase 4: Extensions\nCreate a plugin system for custom markup patterns.` - ) + const value = ref(`# Welcome to **Marked Input** + +This is a *powerful* library for parsing **rich text** with *markdown* formatting. +You can use \`inline code\` snippets like \`const parser = new ParserV2()\` in your text. + +## Features + +- **Bold text** with **strong emphasis** +- *Italic text* and *emphasis* support +- \`Code snippets\` and \`code blocks\` +- ~~Strikethrough~~ for deleted content +- Links like [GitHub](https://github.com) + +## Example + +Here's how to use it: + +\`\`\`javascript +const parser = new ParserV2(['**__value__**', '*__value__*']) +const result = parser.parse('Hello **world**!') +\`\`\` + +Visit our [documentation](https://docs.example.com) for more details. +~~This feature is deprecated~~ and will be removed in v3.0.`) return () => h('div', {style: containerStyle}, [ h(MarkedInput, { @@ -129,13 +124,49 @@ export const MarkdownDocument: Story = { }), } +export const ReadOnlyDraggable: Story = { + render: () => + defineComponent({ + setup() { + const value = `# Read-Only Mode + +Drag handles are hidden in read-only mode. + +## Section A + +Content cannot be reordered. + +## Section B + +This is static content.` + return () => + h('div', {style: containerStyle}, [ + h(MarkedInput, { + Mark: MarkdownMark, + options: markdownOptions, + value, + readOnly: true, + block: true, + style: editorStyle, + }), + ]) + }, + }), +} + export const PlainTextBlocks: Story = { render: () => defineComponent({ setup() { - const value = ref( - `First block of plain text\nSecond block of plain text\nThird block of plain text\nFourth block of plain text\nFifth block of plain text` - ) + const value = ref(`First block of plain text + +Second block of plain text + +Third block of plain text + +Fourth block of plain text + +Fifth block of plain text`) return () => h('div', {style: containerStyle}, [ h(MarkedInput, { diff --git a/packages/website/src/content/docs/api/functions/MarkedInput.md b/packages/website/src/content/docs/api/functions/MarkedInput.md index cc33bcdc..3c723a23 100644 --- a/packages/website/src/content/docs/api/functions/MarkedInput.md +++ b/packages/website/src/content/docs/api/functions/MarkedInput.md @@ -9,7 +9,7 @@ title: "MarkedInput" function MarkedInput(props): Element; ``` -Defined in: [react/markput/src/components/MarkedInput.tsx:77](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L77) +Defined in: [react/markput/src/components/MarkedInput.tsx:79](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L79) ## Type Parameters diff --git a/packages/website/src/content/docs/api/functions/reorderBlocks.md b/packages/website/src/content/docs/api/functions/reorderBlocks.md index 39f562ab..77eb6784 100644 --- a/packages/website/src/content/docs/api/functions/reorderBlocks.md +++ b/packages/website/src/content/docs/api/functions/reorderBlocks.md @@ -13,7 +13,7 @@ function reorderBlocks( targetIndex): string; ``` -Defined in: [common/core/src/features/blocks/reorderBlocks.ts:9](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/reorderBlocks.ts#L9) +Defined in: [common/core/src/features/blocks/reorderBlocks.ts:10](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/reorderBlocks.ts#L10) ## Parameters diff --git a/packages/website/src/content/docs/api/functions/splitTokensIntoBlocks.md b/packages/website/src/content/docs/api/functions/splitTokensIntoBlocks.md index fab9f2e1..c0ef6a06 100644 --- a/packages/website/src/content/docs/api/functions/splitTokensIntoBlocks.md +++ b/packages/website/src/content/docs/api/functions/splitTokensIntoBlocks.md @@ -9,7 +9,7 @@ title: "splitTokensIntoBlocks" function splitTokensIntoBlocks(tokens): Block[]; ``` -Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:20](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L20) +Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:21](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L21) ## Parameters diff --git a/packages/website/src/content/docs/api/interfaces/Block.md b/packages/website/src/content/docs/api/interfaces/Block.md index ad90460c..8ccf34ca 100644 --- a/packages/website/src/content/docs/api/interfaces/Block.md +++ b/packages/website/src/content/docs/api/interfaces/Block.md @@ -5,7 +5,7 @@ prev: false title: "Block" --- -Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:3](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L3) +Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:4](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L4) ## Properties @@ -15,7 +15,7 @@ Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:3](https:/ endPos: number; ``` -Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:7](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L7) +Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:8](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L8) *** @@ -25,7 +25,7 @@ Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:7](https:/ id: string; ``` -Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:4](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L4) +Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:5](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L5) *** @@ -35,7 +35,7 @@ Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:4](https:/ startPos: number; ``` -Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:6](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L6) +Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:7](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L7) *** @@ -45,4 +45,4 @@ Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:6](https:/ tokens: Token[]; ``` -Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:5](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L5) +Defined in: [common/core/src/features/blocks/splitTokensIntoBlocks.ts:6](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts#L6) diff --git a/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md b/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md index 546da716..e5b607e5 100644 --- a/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md +++ b/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md @@ -33,12 +33,17 @@ Props for MarkedInput component. ### block? ```ts -optional block: boolean; +optional block: + | boolean + | { + alwaysShowHandle: boolean; +}; ``` -Defined in: [react/markput/src/components/MarkedInput.tsx:74](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L74) +Defined in: [react/markput/src/components/MarkedInput.tsx:76](https://github.com/Nowely/marked-input/blob/next/packages/react/markput/src/components/MarkedInput.tsx#L76) -Enable Notion-like draggable blocks with drag handles for reordering +Enable Notion-like draggable blocks with drag handles for reordering. +Pass an object to configure block behavior, e.g. `{ alwaysShowHandle: true }` for mobile. *** diff --git a/packages/website/src/content/docs/api/interfaces/MarkputHandler.md b/packages/website/src/content/docs/api/interfaces/MarkputHandler.md index eaaf1b61..ff6634ba 100644 --- a/packages/website/src/content/docs/api/interfaces/MarkputHandler.md +++ b/packages/website/src/content/docs/api/interfaces/MarkputHandler.md @@ -5,7 +5,7 @@ prev: false title: "MarkputHandler" --- -Defined in: [common/core/src/shared/types.ts:129](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L129) +Defined in: [common/core/src/shared/types.ts:130](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L130) ## Properties @@ -15,7 +15,7 @@ Defined in: [common/core/src/shared/types.ts:129](https://github.com/Nowely/mark readonly container: HTMLDivElement | null; ``` -Defined in: [common/core/src/shared/types.ts:130](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L130) +Defined in: [common/core/src/shared/types.ts:131](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L131) *** @@ -25,7 +25,7 @@ Defined in: [common/core/src/shared/types.ts:130](https://github.com/Nowely/mark readonly overlay: HTMLElement | null; ``` -Defined in: [common/core/src/shared/types.ts:131](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L131) +Defined in: [common/core/src/shared/types.ts:132](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L132) ## Methods @@ -35,7 +35,7 @@ Defined in: [common/core/src/shared/types.ts:131](https://github.com/Nowely/mark focus(): void; ``` -Defined in: [common/core/src/shared/types.ts:132](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L132) +Defined in: [common/core/src/shared/types.ts:133](https://github.com/Nowely/marked-input/blob/next/packages/common/core/src/shared/types.ts#L133) #### Returns