feat(tui): per-markdown-block copySource so partial selections round-trip
v1 wrapped each whole message in <Box copySource={msg.text}>. Selecting
a whole assistant message returned the raw markdown — but selecting
just one paragraph, heading, or code fence inside a longer message
still gave back rendered cells (asterisks/headings/fences stripped).
v2 instruments <Md> itself: every top-level block — paragraph, heading,
code fence, list item, table, quote, math block, footnote, etc. —
gets wrapped in its own <Box copySource={rawBlockSource}>. The
parser already advances through lines block by block; we capture
each block's [start, end) line range and group consecutive nodes
emitted by the same iteration into one wrapper after the parse.
Mechanism in markdown.tsx:
- Outer 'while (i < lines.length)' body wrapped in a labeled
'blockIter:' block; every existing 'continue' becomes
'break blockIter' so each branch falls through to a wrap step
that records the block's source range alongside whatever
nodes it pushed.
- Post-loop pass groups consecutive nodes sharing the same range
object and emits one <Box copySource={blockSource}> per group.
Gap nodes (no range) stay flat so empty visual rows don't
inherit a neighboring block's source.
- <StreamingMd> uses <Md> internally for both the stable prefix
and the in-flight suffix → per-block copySource works for
streaming responses too with no further changes.
Nested-region semantics in getSelectedText:
- msg-level <Box copySource={msg.text}> still wraps the whole
message body. When a selection covers the whole message, BOTH
the outer (msg) and inners (every block) end up fully covered.
Without de-dup we'd emit msg.text AND every block's source —
duplicating content.
- computeFullyCoveredCopySources now returns { coveredAll, emit }:
coveredAll is the un-filtered set, emit drops any region whose
bounding rect is strictly contained inside another fully-
covered region. Parent wins; child's text is already inside
parent.source.
- The row-segment loop checks coveredAll to decide 'this segment's
cells are already accounted for by an outer emission, skip',
instead of falling through to extractRowText (which would
duplicate the rendered text after the parent's source string).
Tests (4 new, total 13):
- emit only outer source when both outer and inner are fully covered
- emit only inner source when selection covers just one block
- emit multiple inner blocks when outer is partial but inners full
- keep single fully-covered region (no shadowing partner)
Plus the 9 existing tests, the 5 osc tests, and all 65 ui-tui suites
— 686 passed, 1 skipped, 0 lint or typecheck errors.
This commit is contained in:
@@ -269,4 +269,138 @@ describe('getSelectedText copy-source override', () => {
|
||||
// Region's row 1 is outside selection → partial → fall back to cells.
|
||||
expect(getSelectedText(sel, screen)).toBe('abc')
|
||||
})
|
||||
|
||||
// ── Nested regions (msg-level + per-block) ──
|
||||
// The TUI wraps each message in <Box copySource={msg.text}> AND each
|
||||
// markdown block in <Box copySource={blockSource}>. Children render
|
||||
// AFTER parents in render-node-to-output, so child cells overwrite the
|
||||
// parent's copySource ID — only padding/gaps keep the parent ID. The
|
||||
// parent's bounding rect (computed from its remaining cells) still
|
||||
// approximates its visual extent so containment-based shadowing works.
|
||||
it('emits only the outer source when both outer and inner regions are fully covered', () => {
|
||||
const styles = new StylePool()
|
||||
const charPool = new CharPool()
|
||||
const copySourcePool = new CopySourcePool()
|
||||
const screen = createScreen(20, 4, styles, charPool, new HyperlinkPool(), copySourcePool)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setCellAt(screen, 2 + i, 1, {
|
||||
char: 'hello'[i]!,
|
||||
hyperlink: undefined,
|
||||
styleId: screen.emptyStyleId,
|
||||
width: CellWidth.Narrow
|
||||
})
|
||||
}
|
||||
|
||||
// Outer: msg-level wrapper covers the whole screen rect.
|
||||
const outerId = copySourcePool.intern('# msg context\n\n**hello**\n\nmore msg')
|
||||
markCopySourceRegion(screen, 0, 0, 20, 4, outerId)
|
||||
|
||||
// Inner: block-level wrapper for "hello", overwrites outer cells in
|
||||
// its rect — same order as render-node-to-output (parent first).
|
||||
const innerId = copySourcePool.intern('**hello**')
|
||||
markCopySourceRegion(screen, 2, 1, 5, 1, innerId)
|
||||
|
||||
const sel = createSelectionState()
|
||||
startSelection(sel, 0, 0)
|
||||
updateSelection(sel, 19, 3)
|
||||
|
||||
// Both fully covered, but outer strictly contains inner → emit outer only.
|
||||
expect(getSelectedText(sel, screen)).toBe('# msg context\n\n**hello**\n\nmore msg')
|
||||
})
|
||||
|
||||
it('emits only the inner source when the selection covers just one block', () => {
|
||||
const styles = new StylePool()
|
||||
const charPool = new CharPool()
|
||||
const copySourcePool = new CopySourcePool()
|
||||
const screen = createScreen(20, 4, styles, charPool, new HyperlinkPool(), copySourcePool)
|
||||
|
||||
// Two "blocks" rendered as plain text, both within an outer msg region.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setCellAt(screen, i, 0, {
|
||||
char: 'hello'[i]!,
|
||||
hyperlink: undefined,
|
||||
styleId: screen.emptyStyleId,
|
||||
width: CellWidth.Narrow
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setCellAt(screen, i, 2, {
|
||||
char: 'world'[i]!,
|
||||
hyperlink: undefined,
|
||||
styleId: screen.emptyStyleId,
|
||||
width: CellWidth.Narrow
|
||||
})
|
||||
}
|
||||
|
||||
// Outer covers rows 0..3 (incl. gap rows 1, 3).
|
||||
const outerId = copySourcePool.intern('**hello**\n\n*world*')
|
||||
markCopySourceRegion(screen, 0, 0, 20, 4, outerId)
|
||||
|
||||
// Inner blocks
|
||||
const helloId = copySourcePool.intern('**hello**')
|
||||
const worldId = copySourcePool.intern('*world*')
|
||||
markCopySourceRegion(screen, 0, 0, 5, 1, helloId)
|
||||
markCopySourceRegion(screen, 0, 2, 5, 1, worldId)
|
||||
|
||||
// Selection covers only the "hello" block on row 0.
|
||||
const sel = createSelectionState()
|
||||
startSelection(sel, 0, 0)
|
||||
updateSelection(sel, 4, 0)
|
||||
|
||||
// helloId fully covered (its on-screen cells all in the selection).
|
||||
// outerId NOT fully covered (rows 1-3 outside selection).
|
||||
// worldId NOT fully covered (its row outside selection).
|
||||
expect(getSelectedText(sel, screen)).toBe('**hello**')
|
||||
})
|
||||
|
||||
it('emits multiple inner blocks when outer is partially selected but inners are fully covered', () => {
|
||||
const styles = new StylePool()
|
||||
const charPool = new CharPool()
|
||||
const copySourcePool = new CopySourcePool()
|
||||
const screen = createScreen(20, 5, styles, charPool, new HyperlinkPool(), copySourcePool)
|
||||
|
||||
// Three blocks on rows 0, 2, 4 — outer covers rows 0..4
|
||||
for (const row of [0, 2, 4]) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
setCellAt(screen, i, row, {
|
||||
char: 'abc'[i]!,
|
||||
hyperlink: undefined,
|
||||
styleId: screen.emptyStyleId,
|
||||
width: CellWidth.Narrow
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const outerId = copySourcePool.intern('OUTER')
|
||||
markCopySourceRegion(screen, 0, 0, 20, 5, outerId)
|
||||
|
||||
const block1Id = copySourcePool.intern('# h1')
|
||||
const block2Id = copySourcePool.intern('# h2')
|
||||
const block3Id = copySourcePool.intern('# h3')
|
||||
markCopySourceRegion(screen, 0, 0, 3, 1, block1Id)
|
||||
markCopySourceRegion(screen, 0, 2, 3, 1, block2Id)
|
||||
markCopySourceRegion(screen, 0, 4, 3, 1, block3Id)
|
||||
|
||||
// Selection covers rows 0..2 → blocks 1 and 2 fully covered; outer NOT
|
||||
// (row 4 cells outside selection); block3 NOT (row 4 outside).
|
||||
const sel = createSelectionState()
|
||||
startSelection(sel, 0, 0)
|
||||
updateSelection(sel, 19, 2)
|
||||
|
||||
expect(getSelectedText(sel, screen)).toBe('# h1\n# h2')
|
||||
})
|
||||
|
||||
it('keeps the single fully-covered region (no shadowing partner exists)', () => {
|
||||
// Sanity: containment filter must be a no-op when there's only one
|
||||
// fully-covered region.
|
||||
const { screen } = screenWithCopySource('hi', '# hi heading')
|
||||
|
||||
const sel = createSelectionState()
|
||||
startSelection(sel, 0, 0)
|
||||
updateSelection(sel, 19, 0)
|
||||
|
||||
expect(getSelectedText(sel, screen)).toBe('# hi heading')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -998,7 +998,13 @@ export function getSelectedText(s: SelectionState, screen: Screen): string {
|
||||
// the selection rect — only then is it safe to substitute the source
|
||||
// string (a partial selection has no obvious sub-mapping from rendered
|
||||
// cells back to source characters; v1 punts on that case).
|
||||
const fullyCovered = computeFullyCoveredCopySources(screen, start, end)
|
||||
//
|
||||
// `emit`: regions whose source we'll write out. Excludes inner regions
|
||||
// shadowed by an outer fully-covered region (parent-wins).
|
||||
// `coveredAll`: the un-filtered set, including shadowed ones — used
|
||||
// by the row-segment loop to know "this segment's cells are
|
||||
// already accounted for by an outer emission, skip".
|
||||
const { emit, coveredAll } = computeFullyCoveredCopySources(screen, start, end)
|
||||
|
||||
// Track which copy-source IDs we've already emitted (one source string
|
||||
// covers many rows; we flush it once at the first row of the region in
|
||||
@@ -1034,8 +1040,11 @@ export function getSelectedText(s: SelectionState, screen: Screen): string {
|
||||
segEnd++
|
||||
}
|
||||
|
||||
if (segId !== 0 && fullyCovered.has(segId)) {
|
||||
if (!emittedIds.has(segId)) {
|
||||
if (segId !== 0 && coveredAll.has(segId)) {
|
||||
// This segment's cells are entirely covered by SOME fully-covered
|
||||
// region. Emit our source IFF we're in the post-shadowing emit
|
||||
// set; otherwise an outer region's emission already handles us.
|
||||
if (emit.has(segId) && !emittedIds.has(segId)) {
|
||||
emittedIds.add(segId)
|
||||
const source = screen.copySourcePool.get(segId)
|
||||
|
||||
@@ -1045,8 +1054,7 @@ export function getSelectedText(s: SelectionState, screen: Screen): string {
|
||||
joinRows(lines, source, false)
|
||||
}
|
||||
}
|
||||
// else: already emitted — skip this segment, the earlier
|
||||
// emission's source string already covers all rows of this region.
|
||||
// else: shadowed inner OR already emitted — skip silently.
|
||||
} else {
|
||||
const bounds = selectionContentBounds(screen, row, segStart, segEnd)
|
||||
const segText = bounds ? extractRowText(screen, row, bounds.first, bounds.last) : ''
|
||||
@@ -1088,7 +1096,7 @@ function computeFullyCoveredCopySources(
|
||||
screen: Screen,
|
||||
start: { col: number; row: number },
|
||||
end: { col: number; row: number }
|
||||
): Set<number> {
|
||||
): { coveredAll: Set<number>; emit: Set<number> } {
|
||||
const cs = screen.copySources
|
||||
const w = screen.width
|
||||
const h = screen.height
|
||||
@@ -1113,7 +1121,7 @@ function computeFullyCoveredCopySources(
|
||||
}
|
||||
|
||||
if (ids.size === 0) {
|
||||
return ids
|
||||
return { coveredAll: ids, emit: ids }
|
||||
}
|
||||
|
||||
// Second pass: for each candidate ID, scan the WHOLE screen and find
|
||||
@@ -1179,7 +1187,50 @@ function computeFullyCoveredCopySources(
|
||||
fullyCovered.add(id)
|
||||
}
|
||||
|
||||
return fullyCovered
|
||||
// Fourth pass: nested-region shadowing. When a parent <Box copySource>
|
||||
// wraps child <Box copySource> regions, BOTH may end up fully covered
|
||||
// when the selection spans the parent. Without filtering we'd emit
|
||||
// parent.source AND every child.source — duplicating content.
|
||||
//
|
||||
// Drop a region from the EMIT set when ANOTHER fully-covered region's
|
||||
// bounding rect strictly contains it (parent contains child, parent
|
||||
// wins — its source already includes the child's text). Both regions
|
||||
// remain in `coveredAll` so the row-segment loop knows a shadowed
|
||||
// child's cells are accounted for by the parent (don't fall back to
|
||||
// rendered cells when scanning across them).
|
||||
if (fullyCovered.size > 1) {
|
||||
const emit = new Set<number>(fullyCovered)
|
||||
|
||||
for (const innerId of fullyCovered) {
|
||||
const inner = idBounds.get(innerId)!
|
||||
|
||||
for (const outerId of fullyCovered) {
|
||||
if (innerId === outerId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const outer = idBounds.get(outerId)!
|
||||
const containsRow = outer.minRow <= inner.minRow && outer.maxRow >= inner.maxRow
|
||||
const containsCol = outer.minCol <= inner.minCol && outer.maxCol >= inner.maxCol
|
||||
|
||||
const strictlyLarger =
|
||||
outer.minRow < inner.minRow ||
|
||||
outer.maxRow > inner.maxRow ||
|
||||
outer.minCol < inner.minCol ||
|
||||
outer.maxCol > inner.maxCol
|
||||
|
||||
if (containsRow && containsCol && strictlyLarger) {
|
||||
emit.delete(innerId)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { coveredAll: fullyCovered, emit }
|
||||
}
|
||||
|
||||
return { coveredAll: fullyCovered, emit: fullyCovered }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -382,6 +382,18 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
const lines = ensureEmojiPresentation(text).split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
// Parallel array: nodeRanges[k] = { start, end } means nodes[k] (and any
|
||||
// contiguous run sharing the same range) was emitted by the block
|
||||
// covering source lines [start..end). Used after the parse to wrap
|
||||
// each block's rendered nodes in a <Box copySource=...> so partial
|
||||
// selections of an individual block (one paragraph, one heading, one
|
||||
// code fence) round-trip the raw markdown for THAT block.
|
||||
//
|
||||
// Outer msg-level copySource on <MessageLine> covers the whole
|
||||
// message; the per-block wraps shadow it only when the selection
|
||||
// covers a single block (see computeFullyCoveredCopySources's
|
||||
// containment check).
|
||||
const nodeRanges: Array<{ end: number; start: number } | null> = []
|
||||
|
||||
let prevKind: Kind = null
|
||||
let i = 0
|
||||
@@ -402,8 +414,24 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
const key = nodes.length
|
||||
// Track this iteration's block range so we can wrap whatever it
|
||||
// pushes in a <Box copySource=...> covering the raw source lines.
|
||||
// Selection that fully covers this block's rendered cells will
|
||||
// copy the original markdown (asterisks, fences, headings, etc.)
|
||||
// instead of the stripped render. Outer msg-level copySource on
|
||||
// <MessageLine> still wins for whole-message selections via the
|
||||
// shadowing logic in getSelectedText.
|
||||
const blockStart = i
|
||||
const beforeCount = nodes.length
|
||||
|
||||
// Labeled inner block: each branch's `break blockIter` exits to
|
||||
// the wrap step below instead of skipping it (which a plain
|
||||
// `continue` on the outer while would do). The branches still
|
||||
// advance `i` themselves so the loop progresses normally.
|
||||
|
||||
blockIter: {
|
||||
const line = lines[i]!
|
||||
const key = nodes.length
|
||||
|
||||
if (!line.trim()) {
|
||||
if (!compact) {
|
||||
@@ -412,13 +440,13 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (AUDIO_DIRECTIVE_RE.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const media = line.match(MEDIA_LINE_RE)?.[1]
|
||||
@@ -438,7 +466,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const fence = line.match(FENCE_RE)
|
||||
@@ -467,7 +495,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
start('paragraph')
|
||||
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
start('code')
|
||||
@@ -514,7 +542,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const mathOpen = line.match(MATH_BLOCK_OPEN_RE)
|
||||
@@ -542,7 +570,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
// Multi-line block: scan ahead for a real closer before committing.
|
||||
@@ -563,7 +591,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (headRest.trim()) {
|
||||
@@ -592,7 +620,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i = closeIdx + 1
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const heading = line.match(HEADING_RE)?.[2]
|
||||
@@ -606,7 +634,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
|
||||
@@ -618,7 +646,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i += 2
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (HR_RE.test(line)) {
|
||||
@@ -630,7 +658,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const footnote = line.match(FOOTNOTE_RE)
|
||||
@@ -655,7 +683,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
|
||||
@@ -683,7 +711,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const bullet = line.match(BULLET_RE)
|
||||
@@ -704,7 +732,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const numbered = line.match(NUMBERED_RE)
|
||||
@@ -721,7 +749,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (QUOTE_RE.test(line)) {
|
||||
@@ -748,7 +776,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
|
||||
@@ -762,13 +790,13 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
|
||||
nodes.push(renderTable(key, rows, t))
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (/^<\/?details\b/i.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)?.[1]
|
||||
@@ -782,7 +810,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
@@ -794,7 +822,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||
@@ -816,17 +844,68 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
nodes.push(renderTable(key, rows, t))
|
||||
}
|
||||
|
||||
continue
|
||||
break blockIter
|
||||
}
|
||||
|
||||
start('paragraph')
|
||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||
i++
|
||||
}
|
||||
// End blockIter: the body either fell through (final paragraph
|
||||
// case above advanced i and pushed) or `break blockIter`'d out
|
||||
// of an explicit branch. Either way `i` is now past this block.
|
||||
|
||||
// Wrap step: every node pushed during this iteration belongs to
|
||||
// the same block (lines [blockStart, i)). Record the range — the
|
||||
// post-loop pass will splice each block's nodes into a
|
||||
// <Box copySource=...> wrapper.
|
||||
const blockEnd = i
|
||||
const range = blockEnd > blockStart ? { end: blockEnd, start: blockStart } : null
|
||||
|
||||
for (let k = beforeCount; k < nodes.length; k++) {
|
||||
nodeRanges[k] = range
|
||||
}
|
||||
}
|
||||
|
||||
cacheSet(bucket, cacheKey, nodes)
|
||||
// Post-process: group consecutive nodes with the same block range
|
||||
// and wrap each group in a <Box copySource={raw block source}>.
|
||||
// Nodes with no range (gap pushes) stay flat.
|
||||
const wrapped: ReactNode[] = []
|
||||
let groupStart = 0
|
||||
|
||||
return nodes
|
||||
while (groupStart < nodes.length) {
|
||||
const range = nodeRanges[groupStart] ?? null
|
||||
|
||||
if (!range) {
|
||||
wrapped.push(nodes[groupStart]!)
|
||||
groupStart++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let groupEnd = groupStart + 1
|
||||
|
||||
while (groupEnd < nodes.length && nodeRanges[groupEnd] === range) {
|
||||
groupEnd++
|
||||
}
|
||||
|
||||
const blockSource = lines.slice(range.start, range.end).join('\n')
|
||||
const groupNodes = nodes.slice(groupStart, groupEnd)
|
||||
// Single-node groups skip the extra Box layer when possible — but
|
||||
// we still need a wrapper to carry copySource. flexDirection=
|
||||
// 'column' matches the parent Box at line ~860 so layout stays
|
||||
// identical to the unwrapped version.
|
||||
wrapped.push(
|
||||
<Box copySource={blockSource} flexDirection="column" key={`md-block-${groupStart}`}>
|
||||
{groupNodes}
|
||||
</Box>
|
||||
)
|
||||
groupStart = groupEnd
|
||||
}
|
||||
|
||||
cacheSet(bucket, cacheKey, wrapped)
|
||||
|
||||
return wrapped
|
||||
}, [compact, t, text])
|
||||
|
||||
return <Box flexDirection="column">{nodes}</Box>
|
||||
|
||||
Reference in New Issue
Block a user