@@ -14,6 +14,7 @@ import type {
1414 ParagraphMeasure ,
1515} from '@superdoc/contracts' ;
1616import { computeLinePmRange as computeLinePmRangeUnified , effectiveTableCellSpacing } from '@superdoc/contracts' ;
17+ import { describeCellRenderBlocks , computeCellSliceContentHeight , getEmbeddedRowLines } from '@superdoc/layout-engine' ;
1718import { charOffsetToPm , findCharacterAtX , measureCharacterX } from './text-measurement.js' ;
1819import { clickToPositionDom , findPageElement } from './dom-mapping.js' ;
1920import {
@@ -1381,6 +1382,32 @@ const getCellMeasures = (cell: TableCellMeasure | undefined) => {
13811382 return cell . blocks ?? ( cell . paragraph ? [ cell . paragraph ] : [ ] ) ;
13821383} ;
13831384
1385+ /**
1386+ * Count the number of segments a measured block contributes to getCellLines().
1387+ * Used to advance the global line counter past non-paragraph blocks so that
1388+ * paragraph line ranges stay aligned with the full global index space.
1389+ */
1390+ const countBlockSegments = ( measure : {
1391+ kind : string ;
1392+ rows ?: { cells : unknown [ ] } [ ] ;
1393+ height ?: number ;
1394+ lines ?: unknown [ ] ;
1395+ } ) : number => {
1396+ if ( measure . kind === 'paragraph' ) {
1397+ return ( measure as ParagraphMeasure ) . lines ?. length ?? 0 ;
1398+ }
1399+ if ( measure . kind === 'table' ) {
1400+ let count = 0 ;
1401+ for ( const row of ( measure as TableMeasure ) . rows ) {
1402+ count += getEmbeddedRowLines ( row ) . length ;
1403+ }
1404+ return count ;
1405+ }
1406+ // Image, drawing, other: 1 segment if height > 0
1407+ const h = typeof measure . height === 'number' ? measure . height : 0 ;
1408+ return h > 0 ? 1 : 0 ;
1409+ } ;
1410+
13841411const sumLineHeights = ( measure : ParagraphMeasure , fromLine : number , toLine : number ) => {
13851412 let height = 0 ;
13861413 for ( let i = fromLine ; i < toLine && i < measure . lines . length ; i += 1 ) {
@@ -1657,33 +1684,52 @@ export function selectionToRects(
16571684 const cellBlocks = getCellBlocks ( cell ) ;
16581685 const cellBlockMeasures = getCellMeasures ( cellMeasure ) ;
16591686
1660- // Map each block to its global line range within the cell
1687+ // Build block descriptors for renderer-semantic content height.
1688+ // This fixes the spacing.after bug where the old code used measurement
1689+ // semantics (effectiveTableCellSpacing) for the last block, but the
1690+ // renderer skips spacing.after entirely for the last block.
1691+ const cellRenderBlocks = describeCellRenderBlocks ( cellMeasure , cell , padding ) ;
1692+ const totalCellLines =
1693+ cellRenderBlocks . length > 0 ? cellRenderBlocks [ cellRenderBlocks . length - 1 ] . globalEndLine : 0 ;
1694+ const cellAllowedStart = partialRowData ?. fromLineByCell ?. [ cellIdx ] ?? 0 ;
1695+ const rawCellAllowedEnd = partialRowData ?. toLineByCell ?. [ cellIdx ] ;
1696+ const cellAllowedEnd =
1697+ rawCellAllowedEnd == null || rawCellAllowedEnd === - 1 ? totalCellLines : rawCellAllowedEnd ;
1698+
1699+ // Map each paragraph block to its global line range within the cell.
1700+ // cumulativeLine must advance for ALL block types (not just paragraphs)
1701+ // so that paragraph line ranges align with the global index space used
1702+ // by cellAllowedStart/cellAllowedEnd and computeCellSliceContentHeight.
16611703 const renderedBlocks : Array < {
16621704 block : ParagraphBlock ;
16631705 measure : ParagraphMeasure ;
16641706 startLine : number ;
16651707 endLine : number ;
16661708 height : number ;
1709+ originalBlockIndex : number ;
1710+ globalBlockStart : number ;
16671711 } > = [ ] ;
16681712
16691713 let cumulativeLine = 0 ;
1670- for ( let i = 0 ; i < Math . min ( cellBlocks . length , cellBlockMeasures . length ) ; i += 1 ) {
1714+ const blockCount = Math . min ( cellBlocks . length , cellBlockMeasures . length ) ;
1715+ for ( let i = 0 ; i < blockCount ; i += 1 ) {
16711716 const paraBlock = cellBlocks [ i ] ;
16721717 const paraMeasure = cellBlockMeasures [ i ] ;
16731718 if ( ! paraBlock || ! paraMeasure || paraBlock . kind !== 'paragraph' || paraMeasure . kind !== 'paragraph' ) {
1719+ // Advance cumulativeLine past non-paragraph segments to stay
1720+ // aligned with getCellLines() / describeCellRenderBlocks().
1721+ if ( paraMeasure ) {
1722+ cumulativeLine += countBlockSegments ( paraMeasure ) ;
1723+ }
16741724 continue ;
16751725 }
16761726 const lineCount = paraMeasure . lines . length ;
16771727 const blockStart = cumulativeLine ;
16781728 const blockEnd = cumulativeLine + lineCount ;
16791729 cumulativeLine = blockEnd ;
16801730
1681- const allowedStart = partialRowData ?. fromLineByCell ?. [ cellIdx ] ?? 0 ;
1682- const rawAllowedEnd = partialRowData ?. toLineByCell ?. [ cellIdx ] ;
1683- const allowedEnd = rawAllowedEnd == null || rawAllowedEnd === - 1 ? cumulativeLine : rawAllowedEnd ;
1684-
1685- const renderStartGlobal = Math . max ( blockStart , allowedStart ) ;
1686- const renderEndGlobal = Math . min ( blockEnd , allowedEnd ) ;
1731+ const renderStartGlobal = Math . max ( blockStart , cellAllowedStart ) ;
1732+ const renderEndGlobal = Math . min ( blockEnd , cellAllowedEnd ) ;
16871733 if ( renderStartGlobal >= renderEndGlobal ) continue ;
16881734
16891735 const startLine = renderStartGlobal - blockStart ;
@@ -1697,17 +1743,32 @@ export function selectionToRects(
16971743 height = totalHeight ;
16981744 }
16991745 const isFirstBlock = i === 0 ;
1700- const isLastBlock = i === cellBlocks . length - 1 ;
17011746 const spacingBefore = ( paraBlock as ParagraphBlock ) . attrs ?. spacing ?. before ;
17021747 height += effectiveTableCellSpacing ( spacingBefore , isFirstBlock , padding . top ) ;
1703- const spacingAfter = ( paraBlock as ParagraphBlock ) . attrs ?. spacing ?. after ;
1704- height += effectiveTableCellSpacing ( spacingAfter , isLastBlock , padding . bottom ) ;
1748+ // Match renderer: skip spacing.after for the last block
1749+ const isLastBlock = i === blockCount - 1 ;
1750+ if ( ! isLastBlock ) {
1751+ const spacingAfter = ( paraBlock as ParagraphBlock ) . attrs ?. spacing ?. after ;
1752+ if ( typeof spacingAfter === 'number' && spacingAfter > 0 ) {
1753+ height += spacingAfter ;
1754+ }
1755+ }
17051756 }
17061757
1707- renderedBlocks . push ( { block : paraBlock , measure : paraMeasure , startLine, endLine, height } ) ;
1758+ renderedBlocks . push ( {
1759+ block : paraBlock ,
1760+ measure : paraMeasure ,
1761+ startLine,
1762+ endLine,
1763+ height,
1764+ originalBlockIndex : i ,
1765+ globalBlockStart : blockStart ,
1766+ } ) ;
17081767 }
17091768
1710- const contentHeight = renderedBlocks . reduce ( ( acc , info ) => acc + info . height , 0 ) ;
1769+ // Use shared helper for aggregate content height — keeps selection
1770+ // rects aligned with pagination and the DOM painter.
1771+ const contentHeight = computeCellSliceContentHeight ( cellRenderBlocks , cellAllowedStart , cellAllowedEnd ) ;
17111772 const contentAreaHeight = Math . max ( 0 , rowHeight - ( padding . top + padding . bottom ) ) ;
17121773 const freeSpace = Math . max ( 0 , contentAreaHeight - contentHeight ) ;
17131774
@@ -1721,7 +1782,27 @@ export function selectionToRects(
17211782
17221783 let blockTopCursor = padding . top + verticalOffset ;
17231784
1724- renderedBlocks . forEach ( ( info , blockIndex ) => {
1785+ // Track the global end line of the last processed block so we can
1786+ // advance blockTopCursor past non-paragraph blocks (images, tables)
1787+ // that sit between consecutive paragraphs.
1788+ let prevBlockGlobalEndLine = cellAllowedStart ;
1789+
1790+ renderedBlocks . forEach ( ( info ) => {
1791+ // Advance past any visible non-paragraph blocks between the previous
1792+ // paragraph and this one. Without this, images/tables between
1793+ // paragraphs would be invisible to blockTopCursor and later
1794+ // paragraph rects would be positioned too high.
1795+ for ( const rb of cellRenderBlocks ) {
1796+ if ( rb . kind === 'paragraph' ) continue ;
1797+ if ( rb . visibleHeight === 0 ) continue ;
1798+ if ( rb . globalEndLine <= prevBlockGlobalEndLine ) continue ;
1799+ if ( rb . globalStartLine >= info . globalBlockStart ) break ;
1800+ const localStart = Math . max ( 0 , cellAllowedStart - rb . globalStartLine ) ;
1801+ const localEnd = Math . min ( rb . lineHeights . length , cellAllowedEnd - rb . globalStartLine ) ;
1802+ for ( let li = localStart ; li < localEnd ; li ++ ) {
1803+ blockTopCursor += rb . lineHeights [ li ] ;
1804+ }
1805+ }
17251806 const paragraphMarkerWidth = info . measure . marker ?. markerWidth ?? 0 ;
17261807 // List items in table cells are also rendered with left alignment
17271808 const cellIsListItem = isListItem ( paragraphMarkerWidth , info . block ) ;
@@ -1735,9 +1816,13 @@ export function selectionToRects(
17351816 const intersectingLines = findLinesIntersectingRange ( info . block , info . measure , from , to ) ;
17361817
17371818 // Match renderer: spacing.before is only applied when rendering from the start of the block (startLine === 0).
1819+ // Use the original block index (not renderedBlocks index) so that isFirstBlock matches
1820+ // the renderer's i === 0 check, which includes non-paragraph blocks.
17381821 const rawSpacingBefore = ( info . block as ParagraphBlock ) . attrs ?. spacing ?. before ;
17391822 const effectiveSpacingBeforePx =
1740- info . startLine === 0 ? effectiveTableCellSpacing ( rawSpacingBefore , blockIndex === 0 , padding . top ) : 0 ;
1823+ info . startLine === 0
1824+ ? effectiveTableCellSpacing ( rawSpacingBefore , info . originalBlockIndex === 0 , padding . top )
1825+ : 0 ;
17411826
17421827 intersectingLines . forEach ( ( { line, index } ) => {
17431828 if ( index < info . startLine || index >= info . endLine ) {
@@ -1789,6 +1874,7 @@ export function selectionToRects(
17891874 } ) ;
17901875
17911876 blockTopCursor += info . height ;
1877+ prevBlockGlobalEndLine = info . globalBlockStart + info . endLine ;
17921878 } ) ;
17931879 }
17941880
0 commit comments