@ -18,7 +18,7 @@
* normalize / selection model .
* /
import { createCollapsedSelection , normalizeRange } from '../selection' ;
import { createCollapsedSelection , normalizeRange , type WordsSelection } from '../selection' ;
import { editingContainerPath , pointFromInlineTextOffset , replaceAt } from './helpers' ;
import {
createCallout ,
@ -295,19 +295,26 @@ function insertIntoColumn(
innerBlock . children [ 0 ] . type === 'text' &&
innerBlock . children [ 0 ] . text . length === 0 ;
// An image is a placeholder configured through the inspector (click the
// figure → URL panel), not a block you type after — so it never gets a
// trailing paragraph and never hosts a caret. A divider DOES keep one (it
// separates typed content, so the user needs a line below it).
const isImage = block . type === 'image' ;
let nextInner : readonly WordsBlock [ ] ;
let newInnerIdx : number ;
if ( isEmptyInner ) {
// Always keep a trailing paragraph after the inserted atomic so the
// user can type below it inside the column (mirror of the top-level
// last-block rule). `block` lands at innerIdx; the trailing paragraph
// at innerIdx + 1.
// Keep a trailing paragraph after a divider (or table / callout) so the
// user can type below it (mirror of the top-level last-block rule). An
// image gets none — it would be pure noise. `block` lands at innerIdx;
// the trailing paragraph (when present) at innerIdx + 1.
const isLastInner = innerIdx === col . children . length - 1 ;
const trailing = isImage ? [ ] : [ createParagraph ( ) ] ;
nextInner = isLastInner
? [
. . . col . children . slice ( 0 , innerIdx ) ,
block ,
crea tePa ragraph( ) ,
. . . trailin g,
. . . col . children . slice ( innerIdx + 1 )
]
: [ . . . col . children . slice ( 0 , innerIdx ) , block , . . . col . children . slice ( innerIdx + 1 ) ] ;
@ -338,21 +345,27 @@ function insertIntoColumn(
children : replaceAt ( state . document . children , colsIdx , nextColsBlock )
} ) . document ;
// Atomic blocks (image / divider) have no inline text — placing the
// caret on `[colsIdx, colIdx, newInnerIdx]` makes
// `pointFromInlineTextOffset` fall back to a foreign text node
// (usually doc.children[0]), which then dirty-clears any
// `selectedBlockPath` the caller wants to set. When we just added a
// trailing paragraph for the user to keep typing, prefer that paragraph
// as the caret landing — the model selection stays inside the same
// columns block, no false clearSelectedBlock cascade fires, and the
// inserter can mark the atomic as the visually-selected block.
const isAtomicInserted = block . type === 'image' || block . type === 'divider' ;
// An image has no inline text to host a caret. Return a null model
// selection so the caret never falls back to a foreign text node (which
// would dirty-clear the image highlight). The inspector add-block path
// auto-activates the new block; slash-menu users click the placeholder.
if ( isImage ) {
return changed ( { document : normalized , selection : null , activeMarks : [ ] } ) ;
}
// A divider has no inline text either — placing the caret on
// `[colsIdx, colIdx, newInnerIdx]` makes `pointFromInlineTextOffset` fall
// back to a foreign text node (usually doc.children[0]), which then
// dirty-clears any `selectedBlockPath` the caller wants to set. We added a
// trailing paragraph for the user to keep typing; prefer that paragraph as
// the caret landing. Table / callout carry their own inline text, so their
// caret stays in the block.
const isDividerInserted = block . type === 'divider' ;
const hasTrailingParagraph =
isEmptyInner &&
newInnerIdx + 1 < ( nextCol . children as readonly WordsBlock [ ] ) . length &&
( nextCol . children as readonly WordsBlock [ ] ) [ newInnerIdx + 1 ] ? . type === 'paragraph' ;
const caretInnerIdx = isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx ;
const caretInnerIdx = is Divider Inserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx ;
const targetPath = opts . caretSubPath
? [ colsIdx , colIdx , caretInnerIdx , . . . opts . caretSubPath ]
@ -399,17 +412,24 @@ function insertIntoCell(
innerBlock . children [ 0 ] . type === 'text' &&
innerBlock . children [ 0 ] . text . length === 0 ;
// An image is a placeholder configured through the inspector, not a block
// you type after — no trailing paragraph, no caret (mirror of the column
// rule). A divider keeps its trailing paragraph.
const isImage = block . type === 'image' ;
let nextInner : readonly WordsBlock [ ] ;
let newInnerIdx : number ;
if ( isEmptyInner ) {
// Keep a trailing paragraph after an inserted atomic so the user can
// keep typing below it inside the cell (mirror of the column rule).
// Keep a trailing paragraph after a divider (or table / callout) so the
// user can keep typing below it inside the cell (mirror of the column
// rule). An image gets none.
const isLastInner = innerIdx === cell . children . length - 1 ;
const trailing = isImage ? [ ] : [ createParagraph ( ) ] ;
nextInner = isLastInner
? [
. . . cell . children . slice ( 0 , innerIdx ) ,
block ,
crea tePa ragraph( ) ,
. . . trailin g,
. . . cell . children . slice ( innerIdx + 1 )
]
: [ . . . cell . children . slice ( 0 , innerIdx ) , block , . . . cell . children . slice ( innerIdx + 1 ) ] ;
@ -438,12 +458,20 @@ function insertIntoCell(
children : replaceAt ( state . document . children , tableIdx , nextTable )
} ) . document ;
const isAtomicInserted = block . type === 'image' || block . type === 'divider' ;
// Image: atomic, no inline text — return a null model selection so the
// caret never falls back to a foreign text node (mirror of the column rule).
if ( isImage ) {
return changed ( { document : normalized , selection : null , activeMarks : [ ] } ) ;
}
// Divider: caret lands in its trailing paragraph. Table / callout carry
// their own inline text, so their caret stays in the block.
const isDividerInserted = block . type === 'divider' ;
const hasTrailingParagraph =
isEmptyInner &&
newInnerIdx + 1 < ( nextCell . children as readonly WordsBlock [ ] ) . length &&
( nextCell . children as readonly WordsBlock [ ] ) [ newInnerIdx + 1 ] ? . type === 'paragraph' ;
const caretInnerIdx = isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx ;
const caretInnerIdx = is Divider Inserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx ;
const targetPath = opts . caretSubPath
? [ tableIdx , rowIdx , cellIdx , caretInnerIdx , . . . opts . caretSubPath ]
@ -528,8 +556,13 @@ export function insertBlockInColumn(
col . children [ 0 ] . children [ 0 ] . type === 'text' &&
col . children [ 0 ] . children [ 0 ] . text . length === 0 ;
const isAtomic = block . type === 'image' || block . type === 'divider' ;
const trailing : readonly WordsBlock [ ] = isAtomic ? [ createParagraph ( ) ] : [ ] ;
// A divider keeps a trailing paragraph (type-after escape hatch). An image
// does NOT — it's a placeholder configured via the inspector (the provider
// auto-activates it by id → URL panel), never typed after. So a trailing
// paragraph would be pure noise.
const isImage = block . type === 'image' ;
const isDivider = block . type === 'divider' ;
const trailing : readonly WordsBlock [ ] = isDivider ? [ createParagraph ( ) ] : [ ] ;
const baseChildren : readonly WordsBlock [ ] = isEmptySeed ? [ ] : col . children ;
const nextChildren : readonly WordsBlock [ ] = [ . . . baseChildren , block , . . . trailing ] ;
@ -543,8 +576,14 @@ export function insertBlockInColumn(
children : replaceAt ( doc . children , colsIdx , nextColsBlock )
} ) . document ;
let sel ;
if ( isAtomic ) {
let sel : WordsSelection | null ;
if ( isImage ) {
// Atomic, no inline text: a null selection avoids the foreign-node
// fallback that would dirty-clear the image highlight. The provider
// auto-activates the new block by id, so the inspector follows it to
// the URL panel without a caret.
sel = null ;
} else if ( isDivider ) {
// Caret lands inside the trailing paragraph (offset 0). Selection
// stays INSIDE the columns block — the column-inserter caller can
// then mark the atomic block as visually-selected without the
@ -579,7 +618,7 @@ export function insertBlockInColumn(
return changed ( {
document : normalized ,
selection : sel ,
activeMarks : getActiveMarksForSelection( normalized , sel )
activeMarks : sel ? getActiveMarksForSelection( normalized , sel ) : [ ]
} ) ;
}