| 1 | function isTranscriptContentShrink(delta: number): boolean { |
| 2 | return delta < -0.5; |
| 3 | } |
| 4 | |
| 5 | export const TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX = 4; |
| 6 | |
| 7 | export type TranscriptFollowGeometry = { |
| 8 | contentExtent: number | null; |
| 9 | viewportExtent: number | null; |
| 10 | }; |
| 11 | |
| 12 | type NativeTranscriptGeometry = { |
| 13 | scrollHeight: number; |
| 14 | clientHeight: number; |
| 15 | scrollTop?: number; |
| 16 | }; |
| 17 | |
| 18 | // Negative means pending confirmation; positive means confirmed reachable tail. |
| 19 | const nativeTranscriptTailResiduals = new WeakMap<object, number>(); |
| 20 | |
| 21 | /** Remember a small, synchronous native clamp after repeated tail writes. |
| 22 | * WebView2 can expose a stable scrollHeight whose last few pixels are not |
| 23 | * reachable through scrollTop. A single no-op can instead be a compositor |
| 24 | * restoring a stale range, so require the same residual on stable geometry |
| 25 | * before accepting it. Large gaps still use the LAST-item recovery path. */ |
| 26 | export function observeNativeTranscriptTailClamp( |
| 27 | element: NativeTranscriptGeometry & { scrollTop: number }, |
| 28 | previousTop: number, |
| 29 | ): boolean { |
| 30 | const theoreticalTop = Math.max(0, element.scrollHeight - element.clientHeight); |
| 31 | const residual = theoreticalTop - element.scrollTop; |
| 32 | if ( |
| 33 | Math.abs(element.scrollTop - previousTop) > 0.5 |
| 34 | || residual <= TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX |
| 35 | || residual > 64 |
| 36 | ) { |
| 37 | nativeTranscriptTailResiduals.delete(element); |
| 38 | return false; |
| 39 | } |
| 40 | const observed = nativeTranscriptTailResiduals.get(element); |
| 41 | if (observed != null && Math.abs(observed) === residual) { |
| 42 | nativeTranscriptTailResiduals.set(element, residual); |
| 43 | return true; |
| 44 | } |
| 45 | nativeTranscriptTailResiduals.set(element, -residual); |
| 46 | return false; |
| 47 | } |
| 48 | |
| 49 | export function nativeTranscriptDistanceFromBottom(element: { |
| 50 | scrollHeight: number; |
| 51 | scrollTop: number; |
| 52 | clientHeight: number; |
| 53 | }) { |
| 54 | return nativeTranscriptBottomTop(element) - element.scrollTop; |
| 55 | } |
| 56 | |
| 57 | export function nativeTranscriptBottomTop(element: NativeTranscriptGeometry) { |
| 58 | const theoreticalTop = tailTop(element); |
| 59 | const residual = Math.max(0, nativeTranscriptTailResiduals.get(element) ?? 0); |
| 60 | const observedTop = theoreticalTop - residual; |
| 61 | if (element.scrollTop == null || element.scrollTop <= observedTop + TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX) return observedTop; |
| 62 | nativeTranscriptTailResiduals.delete(element); |
| 63 | return theoreticalTop; |
| 64 | } |
| 65 | |
| 66 | /** Explicit tail transactions always probe the theoretical native extent. |
| 67 | * A confirmed WebView2 clamp remains the logical bottom if that write is a |
| 68 | * no-op, but a browser that later accepts the last pixels clears the residual. */ |
| 69 | export function tailTop(element: NativeTranscriptGeometry) { |
| 70 | return Math.max(0, element.scrollHeight - element.clientHeight); |
| 71 | } |
| 72 | |
| 73 | export function hasTranscriptScrollableRange( |
| 74 | element: NativeTranscriptGeometry, |
| 75 | threshold = TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX, |
| 76 | ) { |
| 77 | return nativeTranscriptBottomTop(element) > threshold; |
| 78 | } |
| 79 | |
| 80 | export function pinTranscriptTailAfterViewportShrink( |
| 81 | element: { scrollHeight: number; scrollTop: number; clientHeight: number }, |
| 82 | geometry: TranscriptFollowGeometry, |
| 83 | tailFollow: boolean, |
| 84 | ): number | null { |
| 85 | const viewport = Math.max(0, element.clientHeight); |
| 86 | const viewportShrunk = geometry.viewportExtent != null |
| 87 | && geometry.viewportExtent - viewport > TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX; |
| 88 | geometry.viewportExtent = viewport; |
| 89 | const contentShrunk = geometry.contentExtent != null |
| 90 | && isTranscriptContentShrink(element.scrollHeight - geometry.contentExtent); |
| 91 | if (!tailFollow || !viewportShrunk || contentShrunk) return null; |
| 92 | const bottom = nativeTranscriptBottomTop(element); |
| 93 | return bottom - element.scrollTop > TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX ? bottom : null; |
| 94 | } |
| 95 |