Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f96894a
feat(blocks): enhance block operations and UI interactions
Nowely Mar 7, 2026
5834daf
fix(pnpm): update package links to point to distribution directories
Nowely Mar 7, 2026
12e94ef
refactor(blocks): streamline block operations and enhance UI interact…
Nowely Mar 7, 2026
ecce9c5
fix(pnpm): update package links to remove 'dist' references
Nowely Mar 7, 2026
cbb48be
feat(blocks): add draggable block stories for React and Vue
Nowely Mar 8, 2026
294563b
feat(blocks): introduce BLOCK_SEPARATOR for improved block management
Nowely Mar 8, 2026
da23171
feat(input): implement #handleEnter method for block insertion on Ent…
Nowely Mar 8, 2026
4da72c3
fix(draggable-block): adjust margin and positioning for read-only mode
Nowely Mar 8, 2026
e643c28
feat(markdown): introduce COMPLEX_MARKDOWN for enhanced story examples
Nowely Mar 8, 2026
31acfa2
feat(block-menu): add 'Add below' functionality to BlockMenu in React…
Nowely Mar 8, 2026
db1ed71
feat(block-menu): add duplicate and delete icons to BlockMenu in Reac…
Nowely Mar 8, 2026
ebe5e7c
refactor(block-menu): standardize icon sizes and styles in BlockMenu …
Nowely Mar 8, 2026
32cede4
feat(icons): implement standardized icon components for BlockMenu in …
Nowely Mar 8, 2026
4f0df6c
refactor(icons): streamline icon usage in BlockMenu and DraggableBloc…
Nowely Mar 8, 2026
d9a85ae
feat(blocks): enhance block management and UI interactions
Nowely Mar 8, 2026
488b049
feat(blocks): add getAlwaysShowHandle function and refactor block val…
Nowely Mar 8, 2026
267ae49
test(blocks): enhance Block component tests for empty block handling
Nowely Mar 8, 2026
cab208c
test(blocks): refactor and enhance block component tests
Nowely Mar 8, 2026
a99d708
feat(blocks): implement backspace functionality for empty blocks and …
Nowely Mar 8, 2026
21d4b4c
fix(blocks): implement Notion-like keyboard navigation for block editor
Nowely Mar 9, 2026
b46e8b7
fix(blocks): correct block handling for empty tokens and enhance bloc…
Nowely Mar 9, 2026
ea77df6
fix(blocks): enhance block input handling and selection behavior
Nowely Mar 9, 2026
50b64de
fix(blocks): improve caret positioning and input handling in block ed…
Nowely Mar 9, 2026
c070e24
fix(blocks): improve caret positioning and handling in KeyDownController
Nowely Mar 9, 2026
37fb369
fix(blocks): update DraggableBlock and MarkedInput documentation
Nowely Mar 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"oxlint --fix",
"oxfmt"
],
"*.{json,md,css,html}": [
"*.{json,css,html}": [
"oxfmt"
]
},
Expand Down
11 changes: 10 additions & 1 deletion packages/common/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
73 changes: 73 additions & 0 deletions packages/common/core/src/features/blocks/blockOperations.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
44 changes: 44 additions & 0 deletions packages/common/core/src/features/blocks/blockOperations.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -42,44 +41,42 @@ 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)

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)
Expand All @@ -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)
})
})
5 changes: 5 additions & 0 deletions packages/common/core/src/features/blocks/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const BLOCK_SEPARATOR = '\n\n'

export function getAlwaysShowHandle(block: boolean | {alwaysShowHandle: boolean}): boolean {
return typeof block === 'object' && !!block.alwaysShowHandle
}
4 changes: 3 additions & 1 deletion packages/common/core/src/features/blocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export {splitTokensIntoBlocks, type Block} from './splitTokensIntoBlocks'
export {reorderBlocks} from './reorderBlocks'
export {reorderBlocks} from './reorderBlocks'
export {addBlock, deleteBlock, duplicateBlock, mergeBlocks} from './blockOperations'
export {BLOCK_SEPARATOR, getAlwaysShowHandle} from './config'
Loading