1- import React , { useEffect , useLayoutEffect , useMemo , useRef , useState , type RefObject } from 'react' ;
1+ import React , { useLayoutEffect , useMemo , useRef , useState , type RefObject } from 'react' ;
22import { useVirtualizer } from '@tanstack/react-virtual' ;
33import { useHighlightedLines } from '../timeline/highlightResources' ;
44import { 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. */
1669export 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