@ -28,11 +28,7 @@
* /
import { samePath , type WordsPath } from '../path' ;
import {
createCollapsedSelection ,
normalizeRange ,
type WordsSelection
} from '../selection' ;
import { createCollapsedSelection , normalizeRange , type WordsSelection } from '../selection' ;
import { deleteRange } from './delete' ;
import {
editingContainerPath ,
@ -127,7 +123,7 @@ function mergeWithPreviousContainer(
}
if ( block . type === 'table' ) {
return mergeTablePrev( state , block , containerPath ) ;
return recurseIntoTableMerge( state , block , blockIndex , 'backward' ) ;
}
if ( block . type === 'callout' && containerPath . length >= 2 ) {
@ -208,11 +204,7 @@ function mergeListPrev(
} ;
const nextList : ListBlock = {
. . . block ,
items : [
. . . block . items . slice ( 0 , itemIndex - 1 ) ,
mergedItem ,
. . . block . items . slice ( itemIndex + 1 )
]
items : [ . . . block . items . slice ( 0 , itemIndex - 1 ) , mergedItem , . . . block . items . slice ( itemIndex + 1 ) ]
} ;
const normalized = normalizeDocument ( {
. . . state . document ,
@ -227,51 +219,64 @@ function mergeListPrev(
} ) ;
}
function mergeTablePrev (
// Cells hold blocks (like callout / column): a caret inside a cell sits in a
// block at path [tableIndex, rowIndex, cellIndex, ...rest]. Treat the cell's
// block list as a sub-document and recurse the delete there — so backspace at
// the start of a NON-first block in a cell merges WITHIN the cell, while at the
// first block it finds no previous block and is a no-op (cells never merge
// across the fixed grid). Mirrors `recurseIntoCalloutMerge`.
function recurseIntoTableMerge (
state : WordsEditorState ,
block : TableBlock ,
containerPath : WordsPath
table : TableBlock ,
tableIndex : number ,
direction : 'backward' | 'forward'
) : WordsOperationResult {
const blockIndex = containerPath [ 0 ] ? ? - 1 ;
const rowIndex = containerPath [ 1 ] ? ? - 1 ;
const cellIndex = containerPath [ 2 ] ? ? - 1 ;
if ( rowIndex < 0 || cellIndex <= 0 ) return noOp ( state ) ;
const row = block . rows [ rowIndex ] ;
const previous = row ? . cells [ cellIndex - 1 ] ;
const current = row ? . cells [ cellIndex ] ;
if ( ! row || ! previous || ! current ) return noOp ( state ) ;
const selection = state . selection ;
if ( ! selection ) return noOp ( state ) ;
const rowIndex = selection . anchor . path [ 1 ] ? ? - 1 ;
const cellIndex = selection . anchor . path [ 2 ] ? ? - 1 ;
const row = table . rows [ rowIndex ] ;
const cell = row ? . cells [ cellIndex ] ;
if ( ! row || ! cell ) return noOp ( state ) ;
const offset = inlineTextLength ( previous . children ) ;
const nextCell : TableCell = {
. . . previous ,
children : ensureInlinesNonEmpty ( [ . . . previous . children , . . . current . children ] )
} ;
const nextRow : TableRow = {
. . . row ,
cells : [
. . . row . cells . slice ( 0 , cellIndex - 1 ) ,
nextCell ,
. . . row . cells . slice ( cellIndex + 1 )
]
const subDoc : WordsDocument = { version : WORDS_VERSION , children : cell.children } ;
const subSelection : WordsSelection = {
anchor : { path : selection.anchor.path.slice ( 3 ) , offset : selection.anchor.offset } ,
focus : { path : selection.focus.path.slice ( 3 ) , offset : selection.focus.offset }
} ;
const nextTable : TableBlock = {
. . . block ,
rows : replaceAt ( block . rows , rowIndex , nextRow )
const subState : WordsEditorState = {
document : subDoc ,
selection : subSelection ,
activeMarks : state.activeMarks
} ;
const subResult = direction === 'backward' ? deleteBackward ( subState ) : deleteForward ( subState ) ;
if ( ! subResult . changed ) return noOp ( state ) ;
const newCell : TableCell = { . . . cell , children : subResult.state.document.children } ;
const newRow : TableRow = { . . . row , cells : replaceAt ( row . cells , cellIndex , newCell ) } ;
const newTable : TableBlock = { . . . table , rows : replaceAt ( table . rows , rowIndex , newRow ) } ;
const subSel = subResult . state . selection ;
const prefix = [ tableIndex , rowIndex , cellIndex ] ;
const newSelection : WordsSelection | null = subSel
? {
anchor : { path : [ . . . prefix , . . . subSel . anchor . path ] , offset : subSel.anchor.offset } ,
focus : { path : [ . . . prefix , . . . subSel . focus . path ] , offset : subSel.focus.offset }
}
: null ;
const normalized = normalizeDocument ( {
. . . state . document ,
children : replaceAt ( state . document . children , blockIndex , nextTable )
children : replaceAt ( state . document . children , tableIndex, new Table)
} ) . document ;
const point = pointFromInlineTextOffset (
normalized ,
[ blockIndex , rowIndex , cellIndex - 1 ] ,
offset
) ;
const sel = createCollapsedSelection ( point . path , point . offset ) ;
return changed ( {
document : normalized ,
selection : sel ,
activeMarks : getActiveMarksForSelection ( normalized , sel )
selection : newSelection ,
activeMarks : newSelection
? getActiveMarksForSelection ( normalized , newSelection )
: state . activeMarks
} ) ;
}
@ -290,7 +295,7 @@ function mergeWithNextContainer(
}
if ( block . type === 'table' ) {
return mergeTableNext( state , block , containerPath ) ;
return recurseIntoTableMerge( state , block , blockIndex , 'forward' ) ;
}
if ( block . type === 'callout' && containerPath . length >= 2 ) {
@ -342,11 +347,7 @@ function mergeListNext(
} ;
const nextList : ListBlock = {
. . . block ,
items : [
. . . block . items . slice ( 0 , itemIndex ) ,
mergedItem ,
. . . block . items . slice ( itemIndex + 2 )
]
items : [ . . . block . items . slice ( 0 , itemIndex ) , mergedItem , . . . block . items . slice ( itemIndex + 2 ) ]
} ;
const normalized = normalizeDocument ( {
. . . state . document ,
@ -361,54 +362,6 @@ function mergeListNext(
} ) ;
}
function mergeTableNext (
state : WordsEditorState ,
block : TableBlock ,
containerPath : WordsPath
) : WordsOperationResult {
const blockIndex = containerPath [ 0 ] ? ? - 1 ;
const rowIndex = containerPath [ 1 ] ? ? - 1 ;
const cellIndex = containerPath [ 2 ] ? ? - 1 ;
if ( rowIndex < 0 || cellIndex < 0 ) return noOp ( state ) ;
const row = block . rows [ rowIndex ] ;
const current = row ? . cells [ cellIndex ] ;
const next = row ? . cells [ cellIndex + 1 ] ;
if ( ! row || ! current || ! next ) return noOp ( state ) ;
const offset = inlineTextLength ( current . children ) ;
const nextCell : TableCell = {
. . . current ,
children : ensureInlinesNonEmpty ( [ . . . current . children , . . . next . children ] )
} ;
const nextRow : TableRow = {
. . . row ,
cells : [
. . . row . cells . slice ( 0 , cellIndex ) ,
nextCell ,
. . . row . cells . slice ( cellIndex + 2 )
]
} ;
const nextTable : TableBlock = {
. . . block ,
rows : replaceAt ( block . rows , rowIndex , nextRow )
} ;
const normalized = normalizeDocument ( {
. . . state . document ,
children : replaceAt ( state . document . children , blockIndex , nextTable )
} ) . document ;
const point = pointFromInlineTextOffset (
normalized ,
[ blockIndex , rowIndex , cellIndex ] ,
offset
) ;
const sel = createCollapsedSelection ( point . path , point . offset ) ;
return changed ( {
document : normalized ,
selection : sel ,
activeMarks : getActiveMarksForSelection ( normalized , sel )
} ) ;
}
// ── Callout recursive descent ────────────────────────────────────────────
function recurseIntoCalloutMerge (
@ -434,8 +387,7 @@ function recurseIntoCalloutMerge(
activeMarks : state.activeMarks
} ;
const subResult =
direction === 'backward' ? deleteBackward ( subState ) : deleteForward ( subState ) ;
const subResult = direction === 'backward' ? deleteBackward ( subState ) : deleteForward ( subState ) ;
if ( ! subResult . changed ) return noOp ( state ) ;
const newCallout : CalloutBlock = {
@ -473,10 +425,7 @@ function recurseIntoCalloutMerge(
// ── Helpers ──────────────────────────────────────────────────────────────
type WordsInlineBlock = Extract <
WordsBlock ,
{ type : 'paragraph' | 'heading' | 'quote' | 'code' }
> ;
type WordsInlineBlock = Extract < WordsBlock , { type : ' paragraph ' | ' heading ' | ' quote ' | ' code ' } > ;
function isInlineBlock ( block : WordsBlock ) : block is WordsInlineBlock {
return (
@ -519,8 +468,6 @@ function mergeInlinesForBlock(
return combined ;
}
function ensureInlinesNonEmpty (
inlines : readonly WordsInline [ ]
) : readonly WordsInline [ ] {
function ensureInlinesNonEmpty ( inlines : readonly WordsInline [ ] ) : readonly WordsInline [ ] {
return inlines . length > 0 ? inlines : [ { type : 'text' , text : '' } ] ;
}