46e2ff57f7
Two follow-up bugs from the initial transcript-virtual selection rewrite:
1. Inline math / bold / links etc copied wrong: selecting 'E = mc^2 or'
from '$E = mc^2$ or' dropped the 'or' because the block-level
simple-offset map assumed rendered cells == source bytes. For
inline-formatted content (math $x$, bold **x**, links [text](url),
code `x`, etc.) that assumption is wrong.
2. Reasoning text in expanded ToolTrail copied as empty (no CopySource
wrapper) — clicking ctrl-c gave nothing.
Fix: rather than recomputing visual->source via width math at the host
level (the broken v1 approach), let the RENDERER attach per-segment
source-byte ranges to the rendered nodes. MdInline already knows
exactly which source bytes each <Text> came from — we just thread
that info through as style.copySourceFragment.
How it flows:
- styles.ts: add copySourceFragment style with start/end/verbatim fields
- Text.tsx: accept copySourceFragment as a prop and forward to ink-text
- squash-text-nodes.ts: propagate copySourceFragment through segment list
(child fragments override parent — bold containing math => inner math
fragment wins)
- render-node-to-output.ts: compute per-row CachedFragment[] for any
ink-text whose segments carry copySourceFragment, attach to nodeCache
- node-cache.ts: CachedLayout gains optional fragments[] field
- copyPointHitTest.ts: when walking up DOM looking for copyRangeId, also
check each rect's fragments[] for one covering (col, row); when found,
return precomputed sourceOffset on the SelectionPoint
- toCopyText.ts: resolvePoint uses point.sourceOffset directly when set,
bypassing getOffset
MdInline now wraps each emitted segment in <Text copySourceFragment={...}>
recording the exact source byte range (with verbatim flag for plain text
+ code spans, false for tokens where rendered cells != source bytes).
Recursive MdInline calls thread sourceOffset down so nested formatting
keeps correct byte positions against the outer block source.
Block branches that go through MdInline (paragraph, heading, bullet,
numbered, setext heading, math fallback) pass the appropriate
sourceOffset of inner text within the block's outerSource so headings
('# Title' source vs 'Title' rendered) map correctly.
Thinking/reasoning fix: ToolTrail and Thinking accept an msgId prop;
when set, Thinking wraps its rendered content in CopySource with
blockIndex=-1 (sorts before reply blocks). outerSource is the full
reasoning text — copy returns full text even when the on-screen preview
is truncated.
Tests: +3 new tests in markdown.test.ts exercising the fragment path
end-to-end (paragraph with inline math, byte-exact partial copy across
formatted spans). All 730 tests pass.
Trade-offs accepted for v2:
- Soft-wrap of an inline-formatted line: fragments only emit on the
FIRST visual row of a wrapped paragraph. Clicks on wrap-continuation
rows fall through to block-level mapping. Adequate — full-paragraph
selections still byte-exact; partial selections that don't cross a
wrap boundary are byte-exact; the only degraded case is partial
selections through a wrap boundary on formatted content.
- Code spans treated as one non-verbatim fragment (snap to start/end on
partial click). Partial code-span selections rare.
- Table cells, quotes, footnotes, definition lists: not yet plumbed
with sourceOffset (still simple-offset). Bullet/numbered/heading/
setext/paragraph/math fallback ARE plumbed (the common cases).
44 lines
1.3 KiB
TypeScript
44 lines
1.3 KiB
TypeScript
/**
|
|
* Bridge between hermes-ink's `copyPointAt` (operates on Ink's internal
|
|
* DOMElement type) and the host's `SelectionPoint` type. The shapes are
|
|
* structurally identical now that `copyPointAt` returns gap adjacency
|
|
* and per-fragment `sourceOffset` directly — this bridge is a thin
|
|
* re-typing layer that keeps the dependency direction clean (host
|
|
* depends on hermes-ink; hermes-ink doesn't import host types).
|
|
*/
|
|
|
|
import { copyPointAt as inkCopyPointAt } from '@hermes/ink'
|
|
|
|
import type { SelectionPoint } from './types.js'
|
|
|
|
type RawPoint =
|
|
| {
|
|
kind: 'in-range'
|
|
rangeId: number
|
|
visualLine: number
|
|
col: number
|
|
sourceOffset?: number
|
|
}
|
|
| { kind: 'gap'; afterRangeId: null | number; beforeRangeId: null | number }
|
|
|
|
export function copyPointFromColRow(rootDom: unknown, col: number, row: number): SelectionPoint {
|
|
const raw = (inkCopyPointAt as (root: unknown, col: number, row: number) => RawPoint)(
|
|
rootDom,
|
|
col,
|
|
row
|
|
)
|
|
|
|
if (raw.kind === 'in-range') {
|
|
return {
|
|
kind: 'in-range',
|
|
rangeId: raw.rangeId,
|
|
visualLine: raw.visualLine,
|
|
col: raw.col,
|
|
...(raw.sourceOffset !== undefined && { sourceOffset: raw.sourceOffset })
|
|
}
|
|
}
|
|
|
|
// Gap: copy adjacency through.
|
|
return { kind: 'gap', afterRangeId: raw.afterRangeId, beforeRangeId: raw.beforeRangeId }
|
|
}
|