Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 54 additions & 1 deletion packages/common/core/src/features/blocks/blockOperations.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest'

import {addBlock, deleteBlock, duplicateBlock} from './blockOperations'
import {addBlock, deleteBlock, duplicateBlock, getMergeJoinPos, mergeBlocks} from './blockOperations'
import type {Block} from './splitTokensIntoBlocks'

function makeBlock(id: string, startPos: number, endPos: number): Block {
Expand Down Expand Up @@ -53,6 +53,59 @@ describe('deleteBlock', () => {
})
})

describe('mergeBlocks', () => {
it('removes the separator between two blocks (standard case)', () => {
// value: "A\n\nB\n\nC", blocks: [0,1], [3,4], [6,7]
expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 1)).toBe('AB\n\nC')
})

it('merges last block into previous', () => {
expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nBC')
})

it('returns value unchanged when index is 0', () => {
expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('A\n\nB\n\nC')
})

describe('embedded separator (mark-ending-with-\\n\\n)', () => {
// When a mark token consumes the \n\n as part of its content, splitTokensIntoBlocks
// sets endPos of that block to AFTER the \n\n, making endPos === next block's startPos.
// Example: "# Heading\n\nBody" where the heading mark includes the trailing \n\n.
// block0: startPos=0, endPos=11 ("# Heading\n\n" length = 11)
// block1: startPos=11, endPos=15 ("Body")

const HEADING_BLOCKS: Block[] = [makeBlock('0', 0, 11), makeBlock('11', 11, 15)]

it('removes the embedded separator from the end of the previous block', () => {
// "# Heading\n\nBody" → "# HeadingBody"
expect(mergeBlocks('# Heading\n\nBody', HEADING_BLOCKS, 1)).toBe('# HeadingBody')
})

it('does not return the original value unchanged', () => {
const result = mergeBlocks('# Heading\n\nBody', HEADING_BLOCKS, 1)
expect(result).not.toBe('# Heading\n\nBody')
})
})
})

describe('getMergeJoinPos', () => {
it('returns endPos of previous block in the standard case', () => {
// THREE_BLOCKS: [0,1], [3,4], [6,7] — endPos=1, next startPos=3 (different)
expect(getMergeJoinPos(THREE_BLOCKS, 1)).toBe(1)
expect(getMergeJoinPos(THREE_BLOCKS, 2)).toBe(4)
})

it('returns endPos minus separator length when separator is embedded in previous mark', () => {
// block0 endPos=11, block1 startPos=11 → separator is embedded, join is at 11-2=9
const HEADING_BLOCKS: Block[] = [makeBlock('0', 0, 11), makeBlock('11', 11, 15)]
expect(getMergeJoinPos(HEADING_BLOCKS, 1)).toBe(9)
})

it('returns 0 for index=0', () => {
expect(getMergeJoinPos(THREE_BLOCKS, 0)).toBe(0)
})
})

describe('duplicateBlock', () => {
it('duplicates last block by appending', () => {
expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB\n\nC\n\nC')
Expand Down
34 changes: 31 additions & 3 deletions packages/common/core/src/features/blocks/blockOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,43 @@ export function deleteBlock(value: string, blocks: Block[], index: number): stri
return value.slice(0, blocks[index].startPos) + value.slice(blocks[index + 1].startPos)
}

/**
* Returns the raw-value position of the join point between block[index-1] and block[index].
*
* Normally this is `blocks[index-1].endPos` — the position right after the previous block's
* content, before the `\n\n` separator.
*
* Exception: when a mark token (e.g. a heading `# …\n\n`) consumes the `\n\n` as part of its
* content, `splitTokensIntoBlocks` sets `endPos = token.position.end` (which is AFTER the `\n\n`)
* and the next block's `startPos` equals that same value. In that case the separator is embedded
* inside the previous block's range, and the true join point is `endPos - BLOCK_SEPARATOR.length`.
*/
export function getMergeJoinPos(blocks: Block[], index: number): number {
if (index <= 0 || index >= blocks.length) return 0
const prev = blocks[index - 1]
const curr = blocks[index]
if (prev.endPos === curr.startPos) {
return prev.endPos - BLOCK_SEPARATOR.length
}
return prev.endPos
}

/**
* Merges block at `index` into block at `index - 1` by removing the separator between them.
* Returns the new value string with the separator removed.
* The caret join point in the raw value is `blocks[index - 1].endPos`.
* Use `getMergeJoinPos` to obtain the raw caret position after the merge.
*/
export function mergeBlocks(value: string, blocks: Block[], index: number): string {
if (index <= 0 || index >= blocks.length) return value
// Remove everything between endPos of previous block and startPos of current block (the separator)
return value.slice(0, blocks[index - 1].endPos) + value.slice(blocks[index].startPos)
const prev = blocks[index - 1]
const curr = blocks[index]
if (prev.endPos === curr.startPos) {
// The \n\n separator is embedded inside the previous block's mark token.
// Remove it from the end of prev's content.
return value.slice(0, prev.endPos - BLOCK_SEPARATOR.length) + value.slice(curr.startPos)
}
// Remove everything between endPos of previous block and startPos of current block
return value.slice(0, prev.endPos) + value.slice(curr.startPos)
}

export function duplicateBlock(value: string, blocks: Block[], index: number): string {
Expand Down
120 changes: 112 additions & 8 deletions packages/common/core/src/features/input/KeyDownController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {NodeProxy} from '../../shared/classes/NodeProxy'
import {KEYBOARD} from '../../shared/constants'
import {deleteBlock, mergeBlocks} from '../blocks/blockOperations'
import {deleteBlock, getMergeJoinPos, mergeBlocks} from '../blocks/blockOperations'
import {BLOCK_SEPARATOR} from '../blocks/config'
import {splitTokensIntoBlocks, type Block} from '../blocks/splitTokensIntoBlocks'
import {Caret} from '../caret'
Expand Down Expand Up @@ -141,16 +141,17 @@ export class KeyDownController {
// Non-empty block at position 0: merge with previous block
if (caretAtStart && blockIndex > 0) {
event.preventDefault()
const joinPos = blocks[blockIndex - 1].endPos
const joinPos = getMergeJoinPos(blocks, blockIndex)
const newValue = mergeBlocks(value, blocks, blockIndex)
this.store.applyValue(newValue)
queueMicrotask(() => {
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)
const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get())
const updatedBlock = updatedBlocks[blockIndex - 1]
if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos)
}
})
return
Expand All @@ -159,7 +160,28 @@ export class KeyDownController {

if (event.key === KEYBOARD.DELETE) {
const blockDiv = blockDivs[blockIndex] as HTMLElement
const caretAtEnd = Caret.getCaretIndex(blockDiv) === blockDiv.textContent?.length
const caretIndex = Caret.getCaretIndex(blockDiv)
const caretAtEnd = caretIndex === blockDiv.textContent?.length
const caretAtStart = caretIndex === 0

// Caret at start of non-first block: merge current block into previous (like Backspace at start)
if (caretAtStart && blockIndex > 0) {
event.preventDefault()
const joinPos = getMergeJoinPos(blocks, blockIndex)
const newValue = mergeBlocks(value, blocks, blockIndex)
this.store.applyValue(newValue)
queueMicrotask(() => {
const newDivs = container.children
const target = newDivs[blockIndex - 1] as HTMLElement | undefined
if (target) {
target.focus()
const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get())
const updatedBlock = updatedBlocks[blockIndex - 1]
if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos)
}
})
return
}

// Caret at end of non-last block: merge next block into current
if (caretAtEnd && blockIndex < blocks.length - 1) {
Expand All @@ -172,8 +194,9 @@ export class KeyDownController {
const target = newDivs[blockIndex] as HTMLElement | undefined
if (target) {
target.focus()
const charOffset = joinPos - block.startPos
Caret.trySetIndex(target, charOffset)
const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get())
const updatedBlock = updatedBlocks[blockIndex]
if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos)
}
})
return
Expand Down Expand Up @@ -527,11 +550,84 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void {
}
}

/**
* Recursively sets the caret inside a mark token's rendered DOM element.
*
* For simple marks (no children), the mark renders its nested content as a single
* text node; we position the caret at `rawAbsolutePos - nested.start` within it.
*
* For complex marks with nested tokens (e.g. a heading that spans multiple inline
* marks), we walk the mark element's childNodes in parallel with token children,
* preferring later tokens at boundaries so a position equal to a mark's end
* resolves into the following sibling text rather than the mark itself.
*
* Returns true when the caret was successfully placed, false when the position
* could not be resolved (caller should fall back to end-of-block).
*/
function setCaretInMarkAtRawPos(markElement: HTMLElement, markToken: MarkToken, rawAbsolutePos: number): boolean {
const sel = window.getSelection()
if (!sel) return false

if (!markToken.children || markToken.children.length === 0) {
// Simple mark: renders nested content as a single text node.
const nestedStart = markToken.nested?.start ?? markToken.position.start
const nestedEnd = markToken.nested?.end ?? markToken.position.end
const offsetInNested = Math.max(0, Math.min(rawAbsolutePos - nestedStart, nestedEnd - nestedStart))
const walker = document.createTreeWalker(markElement, 4 /* SHOW_TEXT */)
const textNode = walker.nextNode() as Text | null
if (!textNode) return false
const range = document.createRange()
range.setStart(textNode, Math.min(offsetInNested, textNode.length))
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
return true
}

// Complex mark: walk childNodes in parallel with token children.
// Comment / fragment nodes are skipped without advancing the token index.
let tokenIdx = 0
for (const childNode of Array.from(markElement.childNodes)) {
if (tokenIdx >= markToken.children.length) break
const tokenChild = markToken.children[tokenIdx]

if (childNode.nodeType === Node.TEXT_NODE && tokenChild.type === 'text') {
if (rawAbsolutePos >= tokenChild.position.start && rawAbsolutePos <= tokenChild.position.end) {
const offset = Math.min(rawAbsolutePos - tokenChild.position.start, (childNode as Text).length)
const range = document.createRange()
range.setStart(childNode, offset)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
return true
}
tokenIdx++
} else if (childNode.nodeType === Node.ELEMENT_NODE && tokenChild.type === 'mark') {
// At a boundary, prefer the next sibling text over the current mark end.
const nextChild = tokenIdx + 1 < markToken.children.length ? markToken.children[tokenIdx + 1] : null
const atBoundary =
rawAbsolutePos === tokenChild.position.end && nextChild?.position.start === rawAbsolutePos
if (
!atBoundary &&
rawAbsolutePos >= tokenChild.position.start &&
rawAbsolutePos <= tokenChild.position.end
) {
return setCaretInMarkAtRawPos(childNode as HTMLElement, tokenChild as MarkToken, rawAbsolutePos)
}
tokenIdx++
}
// Other node types (comments, etc.) — skip without advancing tokenIdx.
}

return false
}

/**
* Sets the caret at an absolute raw-value position within a block's DOM element.
* Finds the token that contains `rawAbsolutePos`, then directly positions the
* caret in that token's DOM text node — bypassing visual character counting
* which is incorrect when mark tokens are present (raw length ≠ visual length).
* For mark tokens, recursively searches nested content via setCaretInMarkAtRawPos.
*/
function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: number): void {
const sel = window.getSelection()
Expand All @@ -545,6 +641,13 @@ function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: n
const domChild = blockChildren[i + 1] as HTMLElement | undefined
if (!domChild) continue

// At a boundary between tokens, prefer the later (next) token so that
// a position equal to the end of a mark resolves into the following text.
const nextToken = block.tokens[i + 1]
if (nextToken && rawAbsolutePos === token.position.end && rawAbsolutePos === nextToken.position.start) {
continue
}

if (rawAbsolutePos >= token.position.start && rawAbsolutePos <= token.position.end) {
if (token.type === 'text') {
const offsetWithinToken = rawAbsolutePos - token.position.start
Expand All @@ -560,7 +663,8 @@ function setCaretAtRawPos(blockDiv: HTMLElement, block: Block, rawAbsolutePos: n
return
}
}
// Mark token: fall through to end-of-block fallback
// Mark token: recurse into its nested DOM content
if (setCaretInMarkAtRawPos(domChild, token as MarkToken, rawAbsolutePos)) return
break
}
}
Expand Down
Loading
Loading