返回 DeepSeek-Reasonix
chatScrollController.ts
根目录 / desktop / frontend / src / lib / chatScrollController.ts
1 import { TranscriptViewportWriter } from "./transcriptViewportWriter";
2
3 type Anchor = { key: string; top: number; turn?: string; previous: string[] };
4 type Position = { following: boolean; anchor?: Anchor };
5 const positions = new Map<string, Position>();
6 let generation = 0;
7
8 /** One native writer, no geometry state in React and no recursive measurement publication. */
9 export class ChatScrollController {
10 readonly generation = ++generation;
11 private writer = new TranscriptViewportWriter();
12 private element?: HTMLElement;
13 private content?: HTMLElement;
14 private observer?: ResizeObserver;
15 private mutations?: MutationObserver;
16 private observedRows = new Set<HTMLElement>();
17 private anchor?: Anchor;
18 private following = true;
19 private observedTop = 0;
20 private userScrollUntil = 0;
21 private disposed = false;
22 private opened = false;
23 private frame = 0;
24 private layoutFrame = 0;
25 private attachment = 0;
26 private transaction = 0;
27 private listeners = new Set<() => void>();
28 private snapshot = { following: true, activeKey: "" };
29 // Reader intent is reported on its own channel: a pending navigation must be
30 // able to yield to a wheel tick without publishing a React-visible snapshot
31 // for every event of the gesture.
32 private readerListeners = new Set<() => void>();
33 private readerEpoch = 0;
34 constructor(readonly sessionKey: string) {}
35 subscribeReaderIntent = (listener: () => void): (() => void) => {
36 this.readerListeners.add(listener);
37 return () => { this.readerListeners.delete(listener); };
38 };
39 private noteReaderIntent() {
40 this.readerEpoch++;
41 for (const listener of [...this.readerListeners]) listener();
42 }
43 getSnapshot = () => this.snapshot;
44 subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
45 private publish() {
46 const activeKey = this.anchor?.turn ?? this.anchor?.key ?? "";
47 if (this.snapshot.following === this.following && this.snapshot.activeKey === activeKey) return;
48 this.snapshot = { following: this.following, activeKey };
49 this.listeners.forEach(listener => listener());
50 }
51 attach(element: HTMLElement, content: HTMLElement) {
52 const attachment = ++this.attachment;
53 this.disposed = false;
54 this.element = element;
55 this.content = content;
56 this.writer.attach(element, this.generation);
57 element.addEventListener("scroll", this.onScroll, { passive: true });
58 element.addEventListener("wheel", this.onWheel, { passive: true });
59 element.addEventListener("touchstart", this.onRead, { passive: true });
60 element.addEventListener("keydown", this.onKey);
61 element.addEventListener("pointerdown", this.onPointer, { passive: true });
62 if (typeof ResizeObserver !== "undefined") {
63 this.observer = new ResizeObserver(() => { if (!this.disposed && attachment === this.attachment) this.scheduleLayout(); });
64 this.observer.observe(content);
65 this.observer.observe(element);
66 }
67 if (typeof MutationObserver !== "undefined") {
68 this.mutations = new MutationObserver(() => {
69 if (this.disposed || attachment !== this.attachment) return;
70 const rows = new Set(this.rows());
71 for (const row of this.observedRows) if (!rows.has(row)) this.observer?.unobserve(row);
72 for (const row of rows) if (!this.observedRows.has(row)) this.observer?.observe(row);
73 this.observedRows = rows;
74 this.scheduleLayout();
75 });
76 // Chat nodes are direct children of the column. Internal Markdown and
77 // tool-body mutations are already covered by the row ResizeObserver;
78 // observing the full subtree would rescan every loaded row for each
79 // worker parse and turns cumulative history loading into quadratic work.
80 this.mutations.observe(content, { childList: true });
81 }
82 }
83 private scheduleLayout() {
84 if (this.layoutFrame) return;
85 const attachment = this.attachment;
86 this.layoutFrame = requestAnimationFrame(() => {
87 this.layoutFrame = 0;
88 if (!this.disposed && attachment === this.attachment) this.layout();
89 });
90 }
91 ready() {
92 if (this.opened || !this.element) return;
93 this.opened = true;
94 const saved = positions.get(this.sessionKey);
95 this.following = saved?.following ?? true;
96 this.anchor = saved?.anchor;
97 this.layout();
98 }
99 private rows() {
100 const content = this.content;
101 if (!content) return [];
102 return Array.from(content.children).filter((row): row is HTMLElement =>
103 row instanceof HTMLElement && row.hasAttribute("data-chat-anchor-key") && row.childNodes.length > 0);
104 }
105 private capture() {
106 const el = this.element;
107 if (!el) return;
108 const top = el.getBoundingClientRect().top;
109 const rows = this.rows();
110 let low = 0, high = rows.length;
111 while (low < high) {
112 const middle = (low + high) >>> 1;
113 if (rows[middle].getBoundingClientRect().bottom <= top + 1) low = middle + 1; else high = middle;
114 }
115 const index = Math.min(low, rows.length - 1);
116 const row = rows[index];
117 if (row) this.anchor = { key: row.dataset.chatAnchorKey!, top: row.getBoundingClientRect().top - top,
118 turn: row.dataset.chatTurn, previous: rows.slice(0, index).map(row => row.dataset.chatAnchorKey!) };
119 }
120 private write(offset: number, owner: "tail-follow" | "restore" = "restore") {
121 if (this.disposed || !this.element) return;
122 this.writer.write({ session: this.sessionKey, generation: this.generation, transactionId: ++this.transaction,
123 geometryRevision: 0, owner, intent: this.following ? "tail" : "reader", offset });
124 this.observedTop = this.element.scrollTop;
125 }
126 layout() {
127 const el = this.element;
128 if (!el || !this.opened || this.disposed) return;
129 if (this.following) this.write(el.scrollHeight, "tail-follow");
130 else if (this.anchor) {
131 const rows = this.rows();
132 let row = rows.find(row => row.dataset.chatAnchorKey === this.anchor!.key);
133 if (!row) row = rows.find(row => row.dataset.chatAnchorKey === `${this.anchor!.turn}:process`);
134 if (!row) {
135 const previous = new Set(this.anchor.previous);
136 row = [...rows].reverse().find(row => previous.has(row.dataset.chatAnchorKey!)) ?? rows[0];
137 }
138 if (row) this.write(el.scrollTop + row.getBoundingClientRect().top - el.getBoundingClientRect().top - this.anchor.top);
139 }
140 this.capture(); this.save(); this.publish();
141 }
142 private save() {
143 positions.delete(this.sessionKey);
144 positions.set(this.sessionKey, { following: this.following, anchor: this.anchor });
145 // Same lifetime policy as inactive view caches; contains identities, never message bodies.
146 if (positions.size > 100) positions.delete(positions.keys().next().value!);
147 }
148 toBottom = () => { this.userScrollUntil = 0; this.following = true; this.anchor = undefined; this.noteReaderIntent(); this.layout(); };
149 /** Leave tail following without reporting reader intent: a navigation jump is
150 * programmatic, so it must not cancel itself. */
151 stopFollowing = () => { this.following = false; this.capture(); this.save(); this.publish(); };
152 jump = (key: string) => {
153 const el = this.element;
154 const row = this.rows().find(row => row.dataset.chatAnchorKey === key);
155 if (!el || !row) return;
156 this.following = false;
157 this.write(el.scrollTop + row.getBoundingClientRect().top - el.getBoundingClientRect().top);
158 this.capture(); this.save(); this.publish();
159 };
160 beforeChange = () => { if (!this.following) this.capture(); };
161 private onRead = () => { this.userScrollUntil = performance.now() + 1000; this.following = false; this.capture(); this.save(); this.publish(); this.noteReaderIntent(); };
162 private onWheel = (event: WheelEvent) => { this.userScrollUntil = performance.now() + 1000; this.noteReaderIntent(); if (event.deltaY < 0) this.onRead(); };
163 private onKey = (event: KeyboardEvent) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) this.onRead(); };
164 private onPointer = (event: PointerEvent) => {
165 const el = this.element;
166 if (el && (event.target === el || event.shiftKey)) this.onRead();
167 };
168 private onScroll = () => {
169 const el = this.element;
170 if (!el || this.disposed) return;
171 const floor = Math.max(0, el.scrollHeight - el.clientHeight);
172 if (Math.abs(el.scrollTop - Math.min(this.observedTop, floor)) > 0.5) {
173 // WebKit can emit a native clamp after asynchronous content replacement.
174 // Only actual input may change reader intent; layout is not user input.
175 if (!this.following || performance.now() < this.userScrollUntil) {
176 this.following = el.scrollTop > this.observedTop && floor - el.scrollTop <= 24;
177 this.capture(); this.save();
178 } else this.scheduleLayout();
179 }
180 this.observedTop = el.scrollTop;
181 if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = 0; if (!this.disposed) this.publish(); });
182 };
183 dispose() {
184 this.attachment++;
185 this.save(); this.disposed = true;
186 const el = this.element;
187 el?.removeEventListener("scroll", this.onScroll);
188 el?.removeEventListener("wheel", this.onWheel);
189 el?.removeEventListener("touchstart", this.onRead);
190 el?.removeEventListener("keydown", this.onKey);
191 el?.removeEventListener("pointerdown", this.onPointer);
192 this.observer?.disconnect();
193 this.mutations?.disconnect(); this.observedRows.clear();
194 cancelAnimationFrame(this.frame); this.frame = 0;
195 cancelAnimationFrame(this.layoutFrame); this.layoutFrame = 0;
196 this.writer.attach(null, ++generation);
197 this.element = undefined; this.content = undefined; this.opened = false;
198 this.listeners.clear(); this.readerListeners.clear();
199 }
200 }
201
201 lines TYPESCRIPT