返回 DeepSeek-Reasonix
transcriptScrollGeometry.ts
根目录 / desktop / frontend / src / lib / transcriptScrollGeometry.ts
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
95 lines TYPESCRIPT