dsh-mega-chat-nav 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/LICENSE +201 -0
  2. package/README.en.md +149 -0
  3. package/README.md +149 -0
  4. package/cordis.patch.yml +4 -0
  5. package/lib/client/NavOverlay.js +35 -0
  6. package/lib/client/NavRail.js +12 -0
  7. package/lib/client/QuestionNavSettingsTab.js +26 -0
  8. package/lib/client/QuestionNavStrip.js +555 -0
  9. package/lib/client/components/ConfigPanel.js +14 -0
  10. package/lib/client/components/DotRail.js +62 -0
  11. package/lib/client/components/NavOverlay.js +121 -0
  12. package/lib/client/components/NavRail.js +97 -0
  13. package/lib/client/components/RailDispatch.js +22 -0
  14. package/lib/client/components/RailHost.js +79 -0
  15. package/lib/client/components/SearchBox.js +66 -0
  16. package/lib/client/components/SettingsPage.js +46 -0
  17. package/lib/client/components/StandaloneSettings.js +16 -0
  18. package/lib/client/components/codex/CodexRail.js +139 -0
  19. package/lib/client/components/deepseek/DeepseekRail.js +264 -0
  20. package/lib/client/components/minimal/MinimalRail.js +135 -0
  21. package/lib/client/components/minimal/cards.js +32 -0
  22. package/lib/client/components/minimal/dot.js +11 -0
  23. package/lib/client/components/minimal/hint.js +30 -0
  24. package/lib/client/components/minimal/paging.js +5 -0
  25. package/lib/client/components/minimal/quick-settings.js +28 -0
  26. package/lib/client/components/minimal/search.js +67 -0
  27. package/lib/client/components/minimal/useRail.js +716 -0
  28. package/lib/client/components/rail/cards.js +32 -0
  29. package/lib/client/components/rail/hint.js +30 -0
  30. package/lib/client/components/rail/paging.js +5 -0
  31. package/lib/client/components/rail/quick-settings.js +28 -0
  32. package/lib/client/components/rail/search.js +76 -0
  33. package/lib/client/components/rail/useRail.js +845 -0
  34. package/lib/client/components/settings/SettingsPage.js +46 -0
  35. package/lib/client/components/settings/SettingsTab.js +17 -0
  36. package/lib/client/components/settings/StandaloneSettings.js +16 -0
  37. package/lib/client/components/shared/Cascade.js +26 -0
  38. package/lib/client/components/shared/ConfigPanel.js +27 -0
  39. package/lib/client/components/shared/Dot.js +8 -0
  40. package/lib/client/components/shared/Hint.js +5 -0
  41. package/lib/client/components/shared/PagingButton.js +5 -0
  42. package/lib/client/components/shared/RailShell.js +78 -0
  43. package/lib/client/components/shared/SearchBox.js +63 -0
  44. package/lib/client/components/shared/hooks.js +427 -0
  45. package/lib/client/components/shared/types.js +1 -0
  46. package/lib/client/config.js +1 -0
  47. package/lib/client/engines/jump.js +34 -0
  48. package/lib/client/engines/useAtBottom.js +25 -0
  49. package/lib/client/engines/useJump.js +40 -0
  50. package/lib/client/engines/useScrollSpy.js +88 -0
  51. package/lib/client/hooks.js +11 -0
  52. package/lib/client/index.js +312 -0
  53. package/lib/client/jump.js +255 -0
  54. package/lib/client/locales.js +142 -0
  55. package/lib/client/pure.js +61 -0
  56. package/lib/client/settings.js +369 -0
  57. package/lib/client/virtual/VirtualList.js +21 -0
  58. package/lib/client.js +3213 -0
  59. package/lib/compat.js +145 -0
  60. package/lib/config.js +37 -0
  61. package/lib/core/active-dot.js +17 -0
  62. package/lib/core/align.js +9 -0
  63. package/lib/core/config.js +37 -0
  64. package/lib/core/focus.js +38 -0
  65. package/lib/core/jump.js +98 -0
  66. package/lib/core/logic/active-dot.js +16 -0
  67. package/lib/core/logic/focus.js +34 -0
  68. package/lib/core/logic/jump.js +188 -0
  69. package/lib/core/logic/nodes.js +59 -0
  70. package/lib/core/model/nav-item.js +1 -0
  71. package/lib/core/model/turns.js +73 -0
  72. package/lib/core/nav-item.js +1 -0
  73. package/lib/core/nodes.js +57 -0
  74. package/lib/core/page-size.js +7 -0
  75. package/lib/core/question-entry.js +1 -0
  76. package/lib/core/time.js +23 -0
  77. package/lib/core/turn-dots.js +85 -0
  78. package/lib/core/utils/measure.js +24 -0
  79. package/lib/core/utils/time.js +26 -0
  80. package/lib/index.js +64 -0
  81. package/lib/nav-item.js +1 -0
  82. package/lib/nodes.js +59 -0
  83. package/lib/projection.js +126 -0
  84. package/lib/schema.js +26 -0
  85. package/lib/search.js +285 -0
  86. package/lib/settings.js +89 -0
  87. package/lib/types/client/NavOverlay.d.ts +18 -0
  88. package/lib/types/client/NavRail.d.ts +7 -0
  89. package/lib/types/client/QuestionNavSettingsTab.d.ts +16 -0
  90. package/lib/types/client/QuestionNavStrip.d.ts +48 -0
  91. package/lib/types/client/components/ConfigPanel.d.ts +14 -0
  92. package/lib/types/client/components/DotRail.d.ts +15 -0
  93. package/lib/types/client/components/NavOverlay.d.ts +30 -0
  94. package/lib/types/client/components/NavRail.d.ts +16 -0
  95. package/lib/types/client/components/RailDispatch.d.ts +8 -0
  96. package/lib/types/client/components/RailHost.d.ts +27 -0
  97. package/lib/types/client/components/SearchBox.d.ts +9 -0
  98. package/lib/types/client/components/SettingsPage.d.ts +9 -0
  99. package/lib/types/client/components/StandaloneSettings.d.ts +13 -0
  100. package/lib/types/client/components/codex/CodexRail.d.ts +3 -0
  101. package/lib/types/client/components/deepseek/DeepseekRail.d.ts +3 -0
  102. package/lib/types/client/components/minimal/MinimalRail.d.ts +8 -0
  103. package/lib/types/client/components/minimal/cards.d.ts +19 -0
  104. package/lib/types/client/components/minimal/dot.d.ts +14 -0
  105. package/lib/types/client/components/minimal/hint.d.ts +11 -0
  106. package/lib/types/client/components/minimal/paging.d.ts +10 -0
  107. package/lib/types/client/components/minimal/quick-settings.d.ts +8 -0
  108. package/lib/types/client/components/minimal/search.d.ts +27 -0
  109. package/lib/types/client/components/minimal/useRail.d.ts +90 -0
  110. package/lib/types/client/components/rail/cards.d.ts +19 -0
  111. package/lib/types/client/components/rail/hint.d.ts +11 -0
  112. package/lib/types/client/components/rail/paging.d.ts +10 -0
  113. package/lib/types/client/components/rail/quick-settings.d.ts +8 -0
  114. package/lib/types/client/components/rail/search.d.ts +34 -0
  115. package/lib/types/client/components/rail/useRail.d.ts +132 -0
  116. package/lib/types/client/components/settings/SettingsPage.d.ts +9 -0
  117. package/lib/types/client/components/settings/SettingsTab.d.ts +6 -0
  118. package/lib/types/client/components/settings/StandaloneSettings.d.ts +13 -0
  119. package/lib/types/client/components/shared/Cascade.d.ts +14 -0
  120. package/lib/types/client/components/shared/ConfigPanel.d.ts +8 -0
  121. package/lib/types/client/components/shared/Dot.d.ts +17 -0
  122. package/lib/types/client/components/shared/Hint.d.ts +6 -0
  123. package/lib/types/client/components/shared/PagingButton.d.ts +10 -0
  124. package/lib/types/client/components/shared/RailShell.d.ts +43 -0
  125. package/lib/types/client/components/shared/SearchBox.d.ts +18 -0
  126. package/lib/types/client/components/shared/hooks.d.ts +64 -0
  127. package/lib/types/client/components/shared/types.d.ts +101 -0
  128. package/lib/types/client/config.d.ts +9 -0
  129. package/lib/types/client/engines/jump.d.ts +17 -0
  130. package/lib/types/client/engines/useAtBottom.d.ts +1 -0
  131. package/lib/types/client/engines/useJump.d.ts +16 -0
  132. package/lib/types/client/engines/useScrollSpy.d.ts +12 -0
  133. package/lib/types/client/hooks.d.ts +7 -0
  134. package/lib/types/client/index.d.ts +31 -0
  135. package/lib/types/client/jump.d.ts +47 -0
  136. package/lib/types/client/locales.d.ts +142 -0
  137. package/lib/types/client/pure.d.ts +21 -0
  138. package/lib/types/client/settings.d.ts +173 -0
  139. package/lib/types/client/virtual/VirtualList.d.ts +15 -0
  140. package/lib/types/compat.d.ts +44 -0
  141. package/lib/types/config.d.ts +44 -0
  142. package/lib/types/core/active-dot.d.ts +15 -0
  143. package/lib/types/core/align.d.ts +10 -0
  144. package/lib/types/core/config.d.ts +44 -0
  145. package/lib/types/core/focus.d.ts +24 -0
  146. package/lib/types/core/jump.d.ts +39 -0
  147. package/lib/types/core/logic/active-dot.d.ts +15 -0
  148. package/lib/types/core/logic/focus.d.ts +22 -0
  149. package/lib/types/core/logic/jump.d.ts +39 -0
  150. package/lib/types/core/logic/nodes.d.ts +48 -0
  151. package/lib/types/core/model/nav-item.d.ts +12 -0
  152. package/lib/types/core/model/turns.d.ts +30 -0
  153. package/lib/types/core/nav-item.d.ts +12 -0
  154. package/lib/types/core/nodes.d.ts +43 -0
  155. package/lib/types/core/page-size.d.ts +8 -0
  156. package/lib/types/core/question-entry.d.ts +12 -0
  157. package/lib/types/core/time.d.ts +4 -0
  158. package/lib/types/core/turn-dots.d.ts +29 -0
  159. package/lib/types/core/utils/measure.d.ts +12 -0
  160. package/lib/types/core/utils/time.d.ts +4 -0
  161. package/lib/types/index.d.ts +4 -0
  162. package/lib/types/nav-item.d.ts +12 -0
  163. package/lib/types/nodes.d.ts +48 -0
  164. package/lib/types/projection.d.ts +71 -0
  165. package/lib/types/schema.d.ts +37 -0
  166. package/lib/types/search.d.ts +42 -0
  167. package/lib/types/settings.d.ts +99 -0
  168. package/package.json +71 -0
  169. package/src/client/components/RailDispatch.tsx +27 -0
  170. package/src/client/components/codex/CodexRail.tsx +348 -0
  171. package/src/client/components/deepseek/DeepseekRail.tsx +522 -0
  172. package/src/client/components/minimal/MinimalRail.tsx +324 -0
  173. package/src/client/components/minimal/dot.tsx +32 -0
  174. package/src/client/components/rail/cards.tsx +102 -0
  175. package/src/client/components/rail/hint.tsx +50 -0
  176. package/src/client/components/rail/paging.tsx +26 -0
  177. package/src/client/components/rail/quick-settings.tsx +130 -0
  178. package/src/client/components/rail/search.tsx +147 -0
  179. package/src/client/components/rail/useRail.ts +906 -0
  180. package/src/client/components/settings/SettingsPage.tsx +198 -0
  181. package/src/client/components/settings/StandaloneSettings.tsx +28 -0
  182. package/src/client/components/shared/types.ts +99 -0
  183. package/src/client/index.ts +388 -0
  184. package/src/client/jump.ts +293 -0
  185. package/src/client/locales.ts +143 -0
  186. package/src/client/settings.ts +482 -0
  187. package/src/client/styles.css +1580 -0
  188. package/src/compat.ts +161 -0
  189. package/src/index.ts +66 -0
  190. package/src/projection.ts +174 -0
  191. package/src/search.ts +291 -0
  192. package/src/settings.ts +169 -0
@@ -0,0 +1,427 @@
1
+ // 共享 hooks:数据装配 / 布局钉位 / scroll-spy / 翻页 / 聚焦 / 提示(各风格复用)
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { groupByTurn, mergeLiveEntries } from "../../../core/model/turns.js";
4
+ import { READING_LINE_TOP, nearestReadDot } from "../../../core/logic/active-dot.js";
5
+ import { DOT_GAP, DOT_SIZE, clampScrollTop, pageStep } from "../../../core/utils/measure.js";
6
+ import { FOCUS_RADIUS, focusCardMetrics, focusScale, focusTier } from "../../../core/logic/focus.js";
7
+ import { formatNavTime } from "../../../core/utils/time.js";
8
+ /** 会话可见性守卫:当前会话存在且非空白 */
9
+ export function useVisibleSession(useSessions) {
10
+ const current = useSessions?.((s) => s.current);
11
+ const summary = useSessions?.((s) => (current === undefined ? undefined : s.byId?.[String(current)]));
12
+ return {
13
+ sessionId: current,
14
+ visible: current !== undefined && summary !== undefined && summary.blank !== true,
15
+ };
16
+ }
17
+ /** 投影值 → 提问条目(结构守卫) */
18
+ function projectionEntries(face) {
19
+ const value = face?.getSnapshot();
20
+ if (!Array.isArray(value))
21
+ return [];
22
+ return value.filter((item) => typeof item === 'object' && item !== null
23
+ && typeof item.id === 'string'
24
+ && typeof item.seq === 'number'
25
+ && typeof item.turn === 'number');
26
+ }
27
+ /** 圆点列表装配:投影(全量)+ 活窗口合并;同内容复用引用(React bail out) */
28
+ export function useSessionDots(injected, sessionId, visible) {
29
+ const [dots, setDots] = useState([]);
30
+ const lastRef = useRef([]);
31
+ useEffect(() => {
32
+ if (!visible || sessionId === undefined || injected === undefined) {
33
+ setDots([]);
34
+ lastRef.current = [];
35
+ return;
36
+ }
37
+ const face = injected.questionProjection(sessionId);
38
+ const refresh = () => {
39
+ const grouped = groupByTurn(projectionEntries(face));
40
+ const next = mergeLiveEntries(grouped, injected.readQuestions(sessionId));
41
+ if (sameDotLists(next, lastRef.current)) {
42
+ setDots(lastRef.current);
43
+ return;
44
+ }
45
+ lastRef.current = next;
46
+ setDots(next);
47
+ };
48
+ refresh();
49
+ const unsubProjection = face?.subscribe(refresh) ?? (() => { });
50
+ const unsubContent = injected.subscribeContent(sessionId, refresh);
51
+ return () => {
52
+ unsubProjection();
53
+ unsubContent();
54
+ };
55
+ // eslint-disable-next-line react-hooks/exhaustive-deps
56
+ }, [injected, sessionId, visible]);
57
+ return dots;
58
+ }
59
+ /** 结构相等:memberKeys 全等即内容一致 */
60
+ function sameDotLists(a, b) {
61
+ if (a.length !== b.length)
62
+ return false;
63
+ for (let i = 0; i < a.length; i++) {
64
+ const da = a[i];
65
+ const db = b[i];
66
+ if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length)
67
+ return false;
68
+ for (let j = 0; j < da.memberKeys.length; j++) {
69
+ if (da.memberKeys[j] !== db.memberKeys[j])
70
+ return false;
71
+ }
72
+ }
73
+ return true;
74
+ }
75
+ /** 会话滚动容器(钉位基准) */
76
+ export function findConvRoot() {
77
+ return document.querySelector('[data-slot="conversation"] > div[data-phase]');
78
+ }
79
+ /**
80
+ * 布局钉位:布局校正循环把 rail 钉到对话列(rAF 连续 3 帧稳定停车;
81
+ * ResizeObserver 观察 frame 与对话根;resize 唤醒)。直接写 panel.style。
82
+ */
83
+ export function useRailLayout(panelRef, visible, align) {
84
+ useEffect(() => {
85
+ if (!visible)
86
+ return;
87
+ let raf = 0;
88
+ let idle = 0;
89
+ let stopped = false;
90
+ let observer = null;
91
+ const observed = { frame: null, convRoot: null };
92
+ const applyLayout = () => {
93
+ const panel = panelRef.current;
94
+ if (panel === null)
95
+ return false;
96
+ const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null;
97
+ const convRoot = findConvRoot();
98
+ if (frame === null || convRoot === null)
99
+ return true;
100
+ if (observer !== null) {
101
+ if (observed.frame !== frame) {
102
+ observer.observe(frame, { box: 'border-box' });
103
+ observed.frame = frame;
104
+ }
105
+ if (observed.convRoot !== convRoot) {
106
+ observer.observe(convRoot, { box: 'border-box' });
107
+ observed.convRoot = convRoot;
108
+ }
109
+ }
110
+ const frameRect = frame.getBoundingClientRect();
111
+ const convRect = convRoot.getBoundingClientRect();
112
+ if (convRect.height <= 0 || convRect.width <= 0)
113
+ return true;
114
+ const top = `${convRect.top - frameRect.top}px`;
115
+ const height = `${convRect.height}px`;
116
+ if (align === 'right') {
117
+ const right = `${frameRect.right - convRect.right}px`;
118
+ if (panel.style.top === top && panel.style.height === height
119
+ && panel.style.right === right && panel.style.left === '')
120
+ return false;
121
+ panel.style.top = top;
122
+ panel.style.height = height;
123
+ panel.style.right = right;
124
+ panel.style.left = '';
125
+ return true;
126
+ }
127
+ const left = `${convRect.left - frameRect.left}px`;
128
+ if (panel.style.top === top && panel.style.height === height
129
+ && panel.style.left === left && panel.style.right === '')
130
+ return false;
131
+ panel.style.top = top;
132
+ panel.style.height = height;
133
+ panel.style.left = left;
134
+ panel.style.right = '';
135
+ return true;
136
+ };
137
+ const loop = () => {
138
+ if (stopped)
139
+ return;
140
+ raf = 0;
141
+ idle = applyLayout() ? 0 : idle + 1;
142
+ if (idle < 3)
143
+ raf = requestAnimationFrame(loop);
144
+ };
145
+ const wake = () => {
146
+ if (stopped)
147
+ return;
148
+ idle = 0;
149
+ if (raf === 0)
150
+ raf = requestAnimationFrame(loop);
151
+ };
152
+ observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake);
153
+ if (observer !== null) {
154
+ const panel = panelRef.current;
155
+ const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null;
156
+ const convRoot = findConvRoot();
157
+ if (frame !== null) {
158
+ observer.observe(frame, { box: 'border-box' });
159
+ observed.frame = frame;
160
+ }
161
+ if (convRoot !== null) {
162
+ observer.observe(convRoot, { box: 'border-box' });
163
+ observed.convRoot = convRoot;
164
+ }
165
+ }
166
+ wake();
167
+ window.addEventListener('resize', wake);
168
+ return () => {
169
+ stopped = true;
170
+ if (raf !== 0)
171
+ cancelAnimationFrame(raf);
172
+ observer?.disconnect();
173
+ window.removeEventListener('resize', wake);
174
+ };
175
+ }, [panelRef, visible, align]);
176
+ }
177
+ /**
178
+ * scroll-spy:标记阅读位置所在 turn(35% 视口线,core/active-dot 判据)。
179
+ * 仅已渲染行参与;rAF 合并 + 捕获式 scroll + ResizeObserver 跟踪 flow 重挂载。
180
+ */
181
+ export function useScrollSpyDot(visible, dots) {
182
+ const [currentKey, setCurrentKey] = useState(null);
183
+ useEffect(() => {
184
+ if (!visible || dots.length === 0) {
185
+ setCurrentKey(null);
186
+ return;
187
+ }
188
+ let raf = 0;
189
+ let disposed = false;
190
+ let observedFlow = null;
191
+ const compute = () => {
192
+ raf = 0;
193
+ const scrollport = document.querySelector('[data-conversation-scroll]');
194
+ const flow = document.querySelector('[data-chat-flow]');
195
+ if (scrollport === null || flow === null) {
196
+ setCurrentKey(null);
197
+ return;
198
+ }
199
+ if (observer !== null && observedFlow !== flow) {
200
+ observer.disconnect();
201
+ observer.observe(flow);
202
+ observedFlow = flow;
203
+ }
204
+ const portRect = scrollport.getBoundingClientRect();
205
+ if (portRect.height <= 0)
206
+ return;
207
+ const rendered = new Map();
208
+ for (const row of flow.querySelectorAll('[data-chat-anchor-key]')) {
209
+ const key = row.dataset.chatAnchorKey;
210
+ if (key !== undefined && !rendered.has(key))
211
+ rendered.set(key, row);
212
+ }
213
+ const rows = [];
214
+ for (const dot of dots) {
215
+ for (const memberKey of dot.memberKeys) {
216
+ const row = rendered.get(memberKey);
217
+ if (row !== undefined) {
218
+ rows.push({ key: dot.key, top: row.getBoundingClientRect().top - portRect.top });
219
+ break;
220
+ }
221
+ }
222
+ }
223
+ const next = nearestReadDot(rows, READING_LINE_TOP);
224
+ setCurrentKey((prev) => (prev === next ? prev : next));
225
+ };
226
+ const schedule = () => {
227
+ if (!disposed && raf === 0)
228
+ raf = requestAnimationFrame(compute);
229
+ };
230
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
231
+ compute();
232
+ document.addEventListener('scroll', schedule, true);
233
+ window.addEventListener('resize', schedule);
234
+ return () => {
235
+ disposed = true;
236
+ if (raf !== 0)
237
+ cancelAnimationFrame(raf);
238
+ observer?.disconnect();
239
+ document.removeEventListener('scroll', schedule, true);
240
+ window.removeEventListener('resize', schedule);
241
+ };
242
+ }, [visible, dots]);
243
+ return [currentKey, setCurrentKey];
244
+ }
245
+ /** 设置订阅:align / bandHeight / scrollBehavior / 显示开关 跟随 */
246
+ export function useNavSettings(injected) {
247
+ const [style, setStyle] = useState(() => injected?.style() ?? 'minimal');
248
+ const [align, setAlign] = useState(() => injected?.align() ?? 'left');
249
+ const [bandHeight, setBandHeight] = useState(() => injected?.bandHeight() ?? 'standard');
250
+ const [cardCount, setCardCount] = useState(() => injected?.cardCount() ?? 5);
251
+ const [scrollBehavior, setScrollBehavior] = useState(() => injected?.scrollBehavior() ?? 'smooth');
252
+ const [showCount, setShowCount] = useState(() => injected?.showCount() ?? 'show');
253
+ const [showSearch, setShowSearch] = useState(() => injected?.showSearch() ?? 'show');
254
+ const [showQuickSettings, setShowQuickSettings] = useState(() => injected?.showQuickSettings() ?? 'show');
255
+ useEffect(() => {
256
+ if (injected === undefined)
257
+ return;
258
+ return injected.subscribeSettings(() => {
259
+ setStyle(injected.style());
260
+ setAlign(injected.align());
261
+ setBandHeight(injected.bandHeight());
262
+ setCardCount(injected.cardCount());
263
+ setScrollBehavior(injected.scrollBehavior());
264
+ setShowCount(injected.showCount());
265
+ setShowSearch(injected.showSearch());
266
+ setShowQuickSettings(injected.showQuickSettings());
267
+ });
268
+ }, [injected]);
269
+ return { style, align, bandHeight, cardCount, scrollBehavior, showCount, showSearch, showQuickSettings };
270
+ }
271
+ /** 翻页模型:▲/▼ 步进 + 溢出检测 + 错峰弹出动画 */
272
+ export function usePaging(listRef, dots, align) {
273
+ const [scrollable, setScrollable] = useState(false);
274
+ const [scrollPos, setScrollPos] = useState({ top: 0, max: 0 });
275
+ const [revealed, setRevealed] = useState(null);
276
+ const revealTimerRef = useRef(null);
277
+ const syncScroll = () => {
278
+ const list = listRef.current;
279
+ if (list === null)
280
+ return;
281
+ setScrollPos({ top: list.scrollTop, max: Math.max(0, list.scrollHeight - list.clientHeight) });
282
+ };
283
+ const pageBy = (dir) => {
284
+ const list = listRef.current;
285
+ if (list === null)
286
+ return;
287
+ const max = Math.max(0, list.scrollHeight - list.clientHeight);
288
+ const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP), max); // 固定 5 节点/页
289
+ if (target === list.scrollTop)
290
+ return;
291
+ const band = list.getBoundingClientRect();
292
+ const els = Array.from(list.querySelectorAll('[data-mega-chat-nav-key]'));
293
+ const wasVisible = els.map((el) => {
294
+ const r = el.getBoundingClientRect();
295
+ return r.bottom > band.top && r.top < band.bottom;
296
+ });
297
+ list.scrollTop = target;
298
+ syncScroll();
299
+ const next = new Map();
300
+ const fresh = [];
301
+ els.forEach((el, i) => {
302
+ if (wasVisible[i])
303
+ return;
304
+ const r = el.getBoundingClientRect();
305
+ const key = el.dataset.megaChatNavKey;
306
+ if (key !== undefined && r.bottom > band.top && r.top < band.bottom)
307
+ fresh.push(key);
308
+ });
309
+ if (dir === 1)
310
+ fresh.reverse();
311
+ fresh.forEach((key, order) => next.set(key, { dir, order }));
312
+ if (next.size === 0)
313
+ return;
314
+ setRevealed(next);
315
+ if (revealTimerRef.current !== null)
316
+ window.clearTimeout(revealTimerRef.current);
317
+ revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120);
318
+ };
319
+ // 溢出检测:dots 或布局变化时重测
320
+ useEffect(() => {
321
+ const list = listRef.current;
322
+ if (list === null)
323
+ return;
324
+ const check = () => {
325
+ setScrollable(list.scrollHeight > list.clientHeight + 1);
326
+ syncScroll();
327
+ };
328
+ check();
329
+ if (typeof ResizeObserver === 'undefined')
330
+ return;
331
+ const observer = new ResizeObserver(check);
332
+ observer.observe(list);
333
+ return () => observer.disconnect();
334
+ // eslint-disable-next-line react-hooks/exhaustive-deps
335
+ }, [dots, align]);
336
+ useEffect(() => () => {
337
+ if (revealTimerRef.current !== null)
338
+ window.clearTimeout(revealTimerRef.current);
339
+ // eslint-disable-next-line react-hooks/exhaustive-deps
340
+ }, []);
341
+ return {
342
+ scrollable,
343
+ scrollPos,
344
+ revealed,
345
+ syncScroll,
346
+ pageBy,
347
+ canPageUp: scrollable && scrollPos.top > 0,
348
+ canPageDown: scrollable && scrollPos.top < scrollPos.max - 1, // 1px 容差:滚动到底不再显示 ▼
349
+ };
350
+ }
351
+ /** 悬停聚焦:选中点 + 邻居窗口 + 级联几何;240ms 清焦延迟(鼠标可移入级联卡) */
352
+ export function useFocus(dots, align, radius = FOCUS_RADIUS) {
353
+ const [focus, setFocus] = useState(null);
354
+ const clearTimerRef = useRef(null);
355
+ const cancelClearFocus = () => {
356
+ if (clearTimerRef.current !== null) {
357
+ window.clearTimeout(clearTimerRef.current);
358
+ clearTimerRef.current = null;
359
+ }
360
+ };
361
+ const scheduleClearFocus = () => {
362
+ cancelClearFocus();
363
+ clearTimerRef.current = window.setTimeout(() => setFocus(null), 240);
364
+ };
365
+ const openFocus = (dot, target) => {
366
+ const index = dots.findIndex((d) => d.key === dot.key);
367
+ if (index < 0)
368
+ return;
369
+ cancelClearFocus();
370
+ const r = target.getBoundingClientRect();
371
+ const horizontal = align === 'right'
372
+ ? { right: window.innerWidth - r.left + 10 }
373
+ : { left: r.right + 10 };
374
+ const lo = Math.max(0, index - radius);
375
+ const hi = Math.min(dots.length - 1, index + radius);
376
+ const items = [];
377
+ for (let i = lo; i <= hi; i++) {
378
+ items.push({ dot: dots[i], distance: Math.abs(i - index) });
379
+ }
380
+ setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal });
381
+ };
382
+ // 半径(卡片数)变化时重算已打开的聚焦窗口
383
+ useEffect(() => {
384
+ if (focus === null)
385
+ return;
386
+ const index = dots.findIndex((d) => d.key === focus.key);
387
+ if (index < 0)
388
+ return;
389
+ const lo = Math.max(0, index - radius);
390
+ const hi = Math.min(dots.length - 1, index + radius);
391
+ const items = [];
392
+ for (let i = lo; i <= hi; i++) {
393
+ items.push({ dot: dots[i], distance: Math.abs(i - index) });
394
+ }
395
+ setFocus((prev) => (prev !== null && prev.key === focus.key ? { ...prev, items } : prev));
396
+ }, [radius, dots, focus?.key]);
397
+ useEffect(() => () => {
398
+ if (clearTimerRef.current !== null)
399
+ window.clearTimeout(clearTimerRef.current);
400
+ // eslint-disable-next-line react-hooks/exhaustive-deps
401
+ }, []);
402
+ return { focus, openFocus, cancelClearFocus, scheduleClearFocus };
403
+ }
404
+ /** 跳转失败提示:监听 mega-chat-nav:jump-failed 事件 */
405
+ export function useJumpHint(t) {
406
+ const [hint, setHint] = useState(null);
407
+ const timerRef = useRef(null);
408
+ useEffect(() => {
409
+ const onJumpFailed = (event) => {
410
+ const code = event.detail;
411
+ const key = code ?? 'TIMEOUT';
412
+ const text = t(['jump.inactive', 'jump.hidden', 'jump.notfound', 'jump.timeout'].includes(key) ? key : 'jump.timeout');
413
+ setHint(text);
414
+ if (timerRef.current !== null)
415
+ window.clearTimeout(timerRef.current);
416
+ timerRef.current = window.setTimeout(() => setHint(null), 1800);
417
+ };
418
+ window.addEventListener('mega-chat-nav:jump-failed', onJumpFailed);
419
+ return () => {
420
+ window.removeEventListener('mega-chat-nav:jump-failed', onJumpFailed);
421
+ if (timerRef.current !== null)
422
+ window.clearTimeout(timerRef.current);
423
+ };
424
+ }, [t]);
425
+ return { hint };
426
+ }
427
+ export { formatNavTime, focusCardMetrics, focusScale, focusTier };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ /** 在滚动容器内按锚点 key 定位行元素(持久 id 精确 → 13:input-message 前缀兜底) */
2
+ export function findAnchorRow(scrollHost, key) {
3
+ const esc = (k) => CSS.escape(k);
4
+ let el = scrollHost.querySelector('[data-chat-anchor-key="' + esc(key) + '"]');
5
+ if (el)
6
+ return el;
7
+ // 兜底:持久 id 以 13:input-message 前缀挂在行上(kind 前缀公式)
8
+ el = scrollHost.querySelector('[data-chat-anchor-key*="' + esc(key) + '"]');
9
+ if (el)
10
+ return el;
11
+ // 兜底:seq 形式(seq:<n>)
12
+ if (key.startsWith('seq:')) {
13
+ const n = key.slice(4);
14
+ el = scrollHost.querySelector('[data-chat-anchor-key*="' + n + '"]');
15
+ if (el)
16
+ return el;
17
+ }
18
+ return null;
19
+ }
20
+ /** 平滑滚动到行(reduced-motion 用瞬时;返回是否成功) */
21
+ export function scrollToRow(scrollHost, row, behavior) {
22
+ const rowRect = row.getBoundingClientRect();
23
+ const hostRect = scrollHost.getBoundingClientRect();
24
+ const target = scrollHost;
25
+ const top = target.scrollTop + (rowRect.top - hostRect.top) - 16;
26
+ scrollHost.scrollTo({ top: Math.max(0, top), behavior });
27
+ return true;
28
+ }
29
+ /** 原文脉冲高亮:给目标行挂临时 class,pulseMs 后移除 */
30
+ export function pulseRow(row, pulseMs) {
31
+ row.classList.add('mgs-jump-flash');
32
+ const timer = setTimeout(() => row.classList.remove('mgs-jump-flash'), pulseMs);
33
+ return () => { clearTimeout(timer); row.classList.remove('mgs-jump-flash'); };
34
+ }
@@ -0,0 +1,25 @@
1
+ // 触底判定(新消息跟随用):scrollTop+clientHeight >= scrollHeight-阈值
2
+ import { useEffect, useRef, useState } from 'react';
3
+ export function useAtBottom(threshold = 24) {
4
+ const [atBottom, setAtBottom] = useState(true);
5
+ const ref = useRef(true);
6
+ ref.current = atBottom;
7
+ useEffect(() => {
8
+ const host = document.querySelector('[data-conversation-scroll]');
9
+ if (!host)
10
+ return;
11
+ const el = host;
12
+ const check = () => {
13
+ const next = el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
14
+ setAtBottom((prev) => (prev === next ? prev : next));
15
+ };
16
+ check();
17
+ el.addEventListener('scroll', check, { passive: true });
18
+ const iv = setInterval(check, 2000);
19
+ return () => {
20
+ el.removeEventListener('scroll', check);
21
+ clearInterval(iv);
22
+ };
23
+ }, [threshold]);
24
+ return atBottom;
25
+ }
@@ -0,0 +1,40 @@
1
+ // 共用跳转 hook:窗口内定位 → 未命中按需扩窗(guard + 串行 loadOlder)→ 原文脉冲高亮
2
+ import { useState } from 'react';
3
+ import { findAnchorRow, scrollToRow, pulseRow } from "./jump.js";
4
+ export function useJump(scrollBehavior, pulseMs, opts) {
5
+ const [failed, setFailed] = useState(false);
6
+ const [jumping, setJumping] = useState(false);
7
+ const jump = async (key) => {
8
+ const host = document.querySelector('[data-conversation-scroll]');
9
+ if (!host) {
10
+ setFailed(true);
11
+ return;
12
+ }
13
+ let row = findAnchorRow(host, key);
14
+ // 窗口外:按需 loadOlder 逐页补载,以目标 key 出现为界(guard 快速失败)
15
+ if (!row && opts?.getSession) {
16
+ const session = opts.getSession();
17
+ const guard = opts.maxPages ?? 120;
18
+ for (let i = 0; i < guard && session; i++) {
19
+ const snap = session.getSnapshot();
20
+ if (!snap.hasMore || snap.loadingOlder)
21
+ break;
22
+ setJumping(true);
23
+ await session.loadOlder();
24
+ row = findAnchorRow(host, key);
25
+ if (row)
26
+ break;
27
+ }
28
+ setJumping(false);
29
+ }
30
+ if (!row) {
31
+ setFailed(true);
32
+ return;
33
+ }
34
+ setJumping(true);
35
+ scrollToRow(host, row, scrollBehavior);
36
+ const clear = pulseRow(row, pulseMs);
37
+ setTimeout(() => { clear(); setJumping(false); setFailed(false); }, Math.max(pulseMs, 800));
38
+ };
39
+ return { failed, jumping, jump };
40
+ }
@@ -0,0 +1,88 @@
1
+ // ScrollSpyEngine:40% 视口线判据 + rAF 单帧合并 + 行锚点缓存(只改 class 不重建 DOM)
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { readingLineY, nearestIndex } from "../pure.js";
4
+ /** 行锚点缓存:一次扫描建立 key → 行元素(MutationObserver 失效重扫) */
5
+ export function scanAnchors(root) {
6
+ const map = new Map();
7
+ for (const el of root.querySelectorAll('[data-chat-anchor-key]')) {
8
+ const k = el.dataset.chatAnchorKey;
9
+ if (k)
10
+ map.set(k, el);
11
+ }
12
+ return map;
13
+ }
14
+ export function useScrollSpy(opts) {
15
+ const [activeKey, setActiveKey] = useState(null);
16
+ const state = useRef(opts);
17
+ state.current = opts;
18
+ const anchorsRef = useRef(null);
19
+ const dirtyRef = useRef(true);
20
+ useEffect(() => {
21
+ if (!opts.enabled || opts.keys.length === 0) {
22
+ setActiveKey(null);
23
+ return;
24
+ }
25
+ const host = document.querySelector('[data-conversation-scroll]');
26
+ if (!host)
27
+ return;
28
+ let raf = 0;
29
+ let lastRun = 0;
30
+ let stopped = false;
31
+ const THROTTLE_MS = 60;
32
+ const ensureAnchors = () => {
33
+ if (dirtyRef.current || !anchorsRef.current) {
34
+ anchorsRef.current = scanAnchors(host);
35
+ dirtyRef.current = false;
36
+ }
37
+ return anchorsRef.current;
38
+ };
39
+ const compute = () => {
40
+ if (stopped)
41
+ return;
42
+ const s = state.current;
43
+ if (s.keys.length === 0)
44
+ return;
45
+ const anchors = ensureAnchors();
46
+ const rect = host.getBoundingClientRect();
47
+ const line = readingLineY({ top: rect.top, height: rect.height }, s.rule);
48
+ const ys = [];
49
+ for (const k of s.keys) {
50
+ const el = anchors.get(k);
51
+ ys.push(el ? el.getBoundingClientRect().top : Infinity);
52
+ }
53
+ const idx = nearestIndex(ys.length, line, (i) => ys[i]);
54
+ let key = idx >= 0 ? s.keys[idx] : null;
55
+ if (s.atBottom?.() && s.keys.length > 0)
56
+ key = s.keys[s.keys.length - 1];
57
+ setActiveKey((prev) => (prev === key ? prev : key));
58
+ };
59
+ const schedule = () => {
60
+ const now = performance.now();
61
+ if (now - lastRun >= THROTTLE_MS) {
62
+ lastRun = now;
63
+ compute();
64
+ }
65
+ else {
66
+ cancelAnimationFrame(raf);
67
+ raf = requestAnimationFrame(schedule);
68
+ }
69
+ };
70
+ const onScroll = () => {
71
+ cancelAnimationFrame(raf);
72
+ raf = requestAnimationFrame(schedule);
73
+ };
74
+ const mo = new MutationObserver(() => { dirtyRef.current = true; });
75
+ mo.observe(host, { subtree: true, childList: true });
76
+ host.addEventListener('scroll', onScroll, { passive: true });
77
+ const iv = setInterval(compute, 2000);
78
+ compute();
79
+ return () => {
80
+ stopped = true;
81
+ cancelAnimationFrame(raf);
82
+ clearInterval(iv);
83
+ mo.disconnect();
84
+ host.removeEventListener('scroll', onScroll);
85
+ };
86
+ }, [opts.enabled, opts.keys.length]);
87
+ return activeKey;
88
+ }
@@ -0,0 +1,11 @@
1
+ // 共享 hooks
2
+ import { useEffect, useState } from 'react';
3
+ export function useScope(scope) {
4
+ const [, force] = useState(0);
5
+ useEffect(() => {
6
+ if (!scope)
7
+ return;
8
+ return scope.subscribe(() => force((n) => n + 1));
9
+ }, [scope]);
10
+ return scope?.getSnapshot().value;
11
+ }