Skip to content

Commit ac1ed8a

Browse files
committed
fix(flow-chat): align virtualized code highlights
Measure native line boxes in pixels and invalidate virtualizer estimates so highlighted rows stay aligned with the source text.\n\nKeep the native text node available for selection while rendering one visible decoration layer, and exclude the split item introduced by a trailing newline. Add regression coverage for unitless line heights, selection rendering, and trailing-newline line counts.
1 parent 48f920c commit ac1ed8a

2 files changed

Lines changed: 101 additions & 20 deletions

File tree

‎src/web-ui/src/flow_chat/components/WindowedCodeContent.test.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import React, { act } from 'react';
33
import { createRoot } from 'react-dom/client';
44
import { expect, it, vi } from 'vitest';
5-
import { WindowedCodeContent } from './WindowedCodeContent';
5+
import { getVisibleCodeLineCount, readLineHeight, WindowedCodeContent } from './WindowedCodeContent';
66

77
const highlights = vi.hoisted(() => ({ ready: false }));
88
vi.mock('../timeline/highlightResources', () => ({ useHighlightedLines: (content: string) => content.split('\n').map(line =>
@@ -31,7 +31,23 @@ it('keeps complete native text and a live range while token decorations change',
3131
expect(source.firstChild).toBe(text);
3232
expect(selection.toString()).toBe(content);
3333
expect(host.querySelector('.code-preview__decorations')?.getAttribute('aria-hidden')).toBe('true');
34+
expect((host.querySelector('.code-preview__decorations') as HTMLElement).style.opacity).not.toBe('0');
3435
} finally {
3536
window.getSelection()?.removeAllRanges(); act(() => root.unmount()); host.remove(); vi.unstubAllGlobals();
3637
}
3738
});
39+
40+
it('converts unitless line-height tokens to pixel virtualizer offsets', () => {
41+
vi.stubGlobal('getComputedStyle', () => ({ lineHeight: '1.5', fontSize: '16px' }));
42+
try {
43+
expect(readLineHeight(document.createElement('code'))).toBe(24);
44+
} finally {
45+
vi.unstubAllGlobals();
46+
}
47+
});
48+
49+
it('does not virtualize the split item after a trailing newline', () => {
50+
expect(getVisibleCodeLineCount(3, true)).toBe(2);
51+
expect(getVisibleCodeLineCount(2, false)).toBe(2);
52+
expect(getVisibleCodeLineCount(1, false)).toBe(1);
53+
});

‎src/web-ui/src/flow_chat/components/WindowedCodeContent.tsx‎

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react';
1+
import React, { useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react';
22
import { useVirtualizer } from '@tanstack/react-virtual';
33
import { useHighlightedLines } from '../timeline/highlightResources';
44
import { getInlineDiffTokenStyle } from './inlineDiffPrismTheme';
@@ -11,6 +11,59 @@ function tokenNode(token: PrismToken, key: number): React.ReactNode {
1111
: typeof token.content === 'string' ? token.content : tokenNode(token.content, 0)}</span>;
1212
}
1313

14+
/**
15+
* Resolve the source line box to pixels before feeding it to the virtualizer.
16+
*
17+
* `line-height` is a unitless design token (`1.52` in the code role). The
18+
* computed style keeps that value unitless, while the virtualizer's offsets
19+
* are pixels. Treating the result of `parseFloat` as pixels makes every later
20+
* decoration row drift farther from the native text row.
21+
*/
22+
export function readLineHeight(source: HTMLElement): number | null {
23+
const computed = getComputedStyle(source);
24+
const rawLineHeight = computed.lineHeight.trim();
25+
const value = Number.parseFloat(rawLineHeight);
26+
if (!Number.isFinite(value) || value <= 0) return null;
27+
28+
if (rawLineHeight.endsWith('px')) return value;
29+
30+
const fontSize = Number.parseFloat(computed.fontSize);
31+
return Number.isFinite(fontSize) && fontSize > 0 ? value * fontSize : null;
32+
}
33+
34+
export function getVisibleCodeLineCount(splitLineCount: number, hasTrailingNewline: boolean): number {
35+
return Math.max(1, splitLineCount - (hasTrailingNewline ? 1 : 0));
36+
}
37+
38+
function readNativeLineRect(source: HTMLElement, lineIndex: number): { top: number; height: number } | null {
39+
const text = source.firstChild;
40+
if (!text || text.nodeType !== Node.TEXT_NODE) return null;
41+
const value = text.textContent ?? '';
42+
let start = 0;
43+
for (let index = 0; index < lineIndex; index += 1) {
44+
const newline = value.indexOf('\n', start);
45+
if (newline < 0) return null;
46+
start = newline + 1;
47+
}
48+
const newline = value.indexOf('\n', start);
49+
const end = newline < 0 ? value.length : newline;
50+
const range = document.createRange();
51+
range.setStart(text, Math.min(start, value.length));
52+
range.setEnd(text, Math.min(Math.max(start + (end - start), start + 1), value.length));
53+
const rect = typeof range.getClientRects === 'function' ? range.getClientRects()[0] : null;
54+
return rect ? { top: rect.top, height: rect.height } : null;
55+
}
56+
57+
function readActualLineHeight(source: HTMLElement, lineCount: number): number | null {
58+
if (lineCount < 2) return null;
59+
const sampleLine = Math.min(lineCount - 1, 32);
60+
const first = readNativeLineRect(source, 0);
61+
const sample = readNativeLineRect(source, sampleLine);
62+
if (!first || !sample || sample.top <= first.top) return null;
63+
const lineHeight = (sample.top - first.top) / sampleLine;
64+
return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : null;
65+
}
66+
1467
/** Keep one native text node for selection/copy/accessibility. Only the expensive
1568
* syntax-token and line-number decorations are virtualized in the existing pane. */
1669
export function WindowedCodeContent({ content, language, scrollerRef, startingLineNumber, showLineNumbers, highlightedLine, onLineClick }: {
@@ -19,43 +72,55 @@ export function WindowedCodeContent({ content, language, scrollerRef, startingLi
1972
onLineClick: (line: number) => void;
2073
}) {
2174
const lines = useHighlightedLines(content, language);
22-
const plain = useMemo(() => content.split('\n'), [content]);
75+
const { plain, visibleLineCount } = useMemo(() => {
76+
const plain = content.split('\n');
77+
return {
78+
plain,
79+
visibleLineCount: getVisibleCodeLineCount(plain.length, content.endsWith('\n')),
80+
};
81+
}, [content]);
2382
const [lineHeight, setLineHeight] = useState(22);
2483
const rootRef = useRef<HTMLPreElement>(null);
2584
const sourceRef = useRef<HTMLElement>(null);
26-
const [selected, setSelected] = useState(false);
27-
const virtualizer = useVirtualizer({ count: plain.length, getScrollElement: () => scrollerRef.current,
85+
const virtualizer = useVirtualizer({ count: visibleLineCount, getScrollElement: () => scrollerRef.current,
2886
estimateSize: () => lineHeight, overscan: 5 });
29-
useEffect(() => {
30-
const update = () => {
31-
const selection = window.getSelection();
32-
setSelected(Boolean(sourceRef.current && selection?.rangeCount && !selection.isCollapsed
33-
&& selection.getRangeAt(0).intersectsNode(sourceRef.current)));
34-
};
35-
document.addEventListener('selectionchange', update);
36-
return () => document.removeEventListener('selectionchange', update);
37-
}, []);
3887
useLayoutEffect(() => {
3988
const source = sourceRef.current;
4089
if (!source) return;
4190
const measure = () => {
42-
const next = Number.parseFloat(getComputedStyle(source).lineHeight);
43-
if (Number.isFinite(next) && next > 0) setLineHeight(next);
91+
const cssLineHeight = readLineHeight(source);
92+
const actualLineHeight = readActualLineHeight(source, visibleLineCount);
93+
const next = actualLineHeight ?? cssLineHeight;
94+
if (next !== null) setLineHeight(next);
4495
};
4596
const observer = new ResizeObserver(measure);
4697
observer.observe(source); measure();
4798
return () => observer.disconnect();
48-
}, [scrollerRef]);
99+
}, [content.length, visibleLineCount, scrollerRef]);
100+
101+
// The first virtualizer pass uses the fallback 22px estimate. Once the
102+
// source line box is measured, invalidate TanStack Virtualizer's cached
103+
// estimates so every row is projected with the resolved line height.
104+
const previousLineHeightRef = useRef(lineHeight);
105+
useLayoutEffect(() => {
106+
if (previousLineHeightRef.current === lineHeight) return;
107+
previousLineHeightRef.current = lineHeight;
108+
virtualizer.measure();
109+
}, [lineHeight, virtualizer]);
110+
49111
return <pre ref={rootRef} className="code-preview__plain" data-openbitfun-component="code-preview" data-openbitfun-part="plain"
50112
style={{ minHeight: virtualizer.getTotalSize(), position: 'relative', width: 'max-content', minWidth: '100%' }}
51113
onClick={event => {
52114
if (!window.getSelection()?.isCollapsed) return;
53115
const index = Math.floor((event.clientY - event.currentTarget.getBoundingClientRect().top) / lineHeight);
54-
if (index >= 0 && index < plain.length) onLineClick(startingLineNumber + index);
116+
if (index >= 0 && index < visibleLineCount) onLineClick(startingLineNumber + index);
55117
}}>
56-
<code ref={sourceRef} data-code-source="" style={{ paddingInlineStart: showLineNumbers ? '3.5em' : undefined }}>{content}</code>
118+
<code ref={sourceRef} data-code-source="" style={{
119+
color: 'transparent',
120+
paddingInlineStart: showLineNumbers ? '3.5em' : undefined,
121+
}}>{content}</code>
57122
<span aria-hidden="true" className="code-preview__decorations"
58-
style={{ pointerEvents: 'none', userSelect: 'none', color: 'transparent', opacity: selected ? 0 : 1 }}>
123+
style={{ pointerEvents: 'none', userSelect: 'none', color: 'var(--openbitfun-color-content-primary)' }}>
59124
{virtualizer.getVirtualItems().map(row => {
60125
const number = startingLineNumber + row.index;
61126
return <span key={row.key} className={`code-preview__plain-line${highlightedLine === number ? ' code-preview__plain-line--highlighted' : ''}`}

0 commit comments

Comments
 (0)