dsh-scroll-piano 0.3.1 → 0.3.3

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 (3) hide show
  1. package/README.md +5 -3
  2. package/lib/client.js +88 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,12 +6,14 @@
6
6
 
7
7
  - 聊天区右边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
8
8
  - 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
9
- - **气泡只在鼠标正落在「你发的某条消息」上时出现**,内容就是那条消息的正文(最多 3 行)+「点击跳转」提示;**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
9
+ - 竖条上有**「我的消息」小地图刻度**:你每发一条消息,对应位置一根蓝色小横杠——**按「在整段对话里的位置」定位,与滚动无关**,所以不管你滚到对话多深,自己的消息始终标在那、点得到。
10
+ - **气泡只在鼠标贴近某条刻度(±16px)时出现**,内容就是那条消息的正文(最多 3 行)+「点击跳转」提示;**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
10
11
  - **点击气泡** → 平滑滚动,把那条消息滚到视口中央。
11
12
  - 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
12
13
  - 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
13
14
 
14
- > 预览**只显示用户自己发的消息**,且**不做「取最近一条」兜底**:滑到 AI 回复 / 工具调用 / 过程块上时气泡整个收起,避免同一条消息在整片区域里被反复显示。声音与预览**彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
15
+ > 预览**只显示用户自己发的消息**,**不做「取全局最近一条」兜底**(否则同一条会在整片区域反复出现 = 重复显示),位置感交给刻度。**声音与预览彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
16
+ > 拿不到聊天区时(欢迎页 / 新会话还没有消息),触发区**退回窗口右边缘**,声音照出——否则那类页面连声音都没有(踩过)。
15
17
 
16
18
  ## 消息预览怎么定位
17
19
 
@@ -23,7 +25,7 @@
23
25
  | `14:assistant-stepN:M` | AI 消息 → 跳过 |
24
26
  | `12:turn-processN` / `["process",…]` / `9:turn-tailN` | 过程 / 工具调用 / 轮次尾 → 跳过 |
25
27
 
26
- 滑动时按鼠标纵坐标 y 找**纵坐标落在其 `getBoundingClientRect()` 内**的那条用户消息,`innerText` 取正文(去掉末尾 `HH:MM` 时间戳);点击时对该元素 `scrollIntoView({ block: "center" })`。**只认这些 `data-*` 属性**——容器类名(`icaHSq_flowItem`、`ST7X_W_scrollBody`)都带 hash,不碰。
28
+ 刻度 y = **消息在滚动内容里的偏移 ÷ 内容总长 × 视口高**(`r.top − 视口top + scrollTop` 得到内容坐标,再归一化),**与 scrollTop 无关**——滚多远都不变,消息不会"滚出屏幕"而看不见。鼠标贴近某条刻度(±16px)就取它 `innerText` 的正文(去掉末尾 `HH:MM` 时间戳)显示,点击时对该元素 `scrollIntoView({ block: "center" })`。**只认这些 `data-*` 属性**——容器类名(`icaHSq_flowItem`、`ST7X_W_scrollBody`)都带 hash,不碰。
27
29
 
28
30
  ## 触发区:聊天区右边缘,不是窗口右边缘
29
31
 
package/lib/client.js CHANGED
@@ -30,6 +30,8 @@ window.__ModuleLoader__.load({
30
30
  ".dshscp_vol{width:76px;accent-color:var(--dsw-alias-state-business-primary,#5b8def)}",
31
31
  ".dshscp_strip{position:fixed;top:0;right:0;width:3px;height:100%;z-index:2147482000;pointer-events:none;background:linear-gradient(180deg,rgba(91,141,239,.85),rgba(160,120,240,.85));opacity:0;transition:opacity .15s ease}",
32
32
  ".dshscp_stripOn{opacity:.55}",
33
+ ".dshscp_ticks{position:fixed;top:0;right:0;width:9px;height:100%;z-index:2147482000;pointer-events:none}",
34
+ ".dshscp_tick{position:absolute;right:0;width:7px;height:2px;border-radius:1px;background:rgba(91,141,239,.95);box-shadow:0 0 4px rgba(91,141,239,.7)}",
33
35
  ".dshscp_marker{position:fixed;right:0;width:7px;height:7px;border-radius:50%;z-index:2147482001;pointer-events:none;background:#fff;box-shadow:0 0 8px rgba(160,120,240,.95);opacity:0;transition:opacity .1s ease}",
34
36
  ".dshscp_markerOn{opacity:1}",
35
37
  ".dshscp_prev{position:fixed;right:18px;z-index:2147482002;pointer-events:none;max-width:min(320px,42vw);padding:7px 10px;border-radius:9px;background:rgba(20,24,32,.95);color:#e6e8ee;box-shadow:0 6px 22px rgba(0,0,0,.45);opacity:0;transition:opacity .1s ease}",
@@ -165,40 +167,60 @@ window.__ModuleLoader__.load({
165
167
  }
166
168
 
167
169
  /**
168
- * 聊天区滚动容器的视口矩形——把触发区钉在**聊天区右边缘**,
169
- * 而不是窗口右边缘:右侧栏(文件预览等)打开时,窗口右边是面板,
170
- * 不该在面板上触发、更不该让气泡盖住面板。
170
+ * 聊天区滚动容器的度量。`right` 用来把触发区钉在聊天区右边缘(右侧栏打开时不盖面板);
171
+ * `top/scrollTop/scrollHeight` 用来把**整段对话**压进竖条做小地图。
172
+ *
173
+ * 拿不到聊天区(欢迎页 / 新会话还没消息)时返回 null——调用方**退回窗口右边缘**,
174
+ * 否则那类页面连钢琴声都不出(踩过)。
171
175
  */
172
176
  function chatViewport() {
173
177
  const el = findChatScroller();
174
178
  if (!el) return null;
175
179
  const r = el.getBoundingClientRect();
176
- return { right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom) };
180
+ return {
181
+ right: Math.round(r.right),
182
+ top: Math.round(r.top),
183
+ scrollTop: el.scrollTop,
184
+ scrollHeight: Math.max(1, el.scrollHeight),
185
+ clientHeight: Math.max(1, el.clientHeight)
186
+ };
177
187
  }
178
188
 
179
189
  /**
180
- * 取**纵坐标 y 落在其范围内**的那条**用户消息**——只预览「我输入的」,
181
- * 不预览 AI 的。稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
182
- * `13:input-message<uuid>`(用户)/ `14:assistant-stepN:M`(AI)/
183
- * `12:turn-processN`(过程块),类名带 hash 所以只认 data 属性。
190
+ * 把**整段对话**映射成竖条上的小地图刻度:每条用户消息一个 y(0..视口高)。
184
191
  *
185
- * 刻意**不做「取最近一条」兜底**:滑到 AI / 过程块上就返回 null(预览收起),
186
- * 否则同一条用户消息会在整片区域里被反复显示(就是「重复显示」)。
192
+ * y 取的是「消息在**滚动内容**里的偏移 / 内容总长」,**与 scrollTop 无关**——
193
+ * 所以不管滚到哪、滚多远,自己的消息刻度**始终在那**,看得见也点得到。
194
+ * (上一版按视口坐标定位:滚到对话底部时你的消息 y 是 -4339,全跑到屏幕外了。)
187
195
  */
188
- function userMessageAtY(y) {
196
+ function minimapTicks(view) {
189
197
  const flow = document.querySelector("[data-chat-flow]");
190
- if (!flow) return null;
198
+ if (!flow || !view) return [];
199
+ const out = [];
191
200
  for (const el of flow.children) {
192
201
  const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
193
202
  if (!/input-message/.test(key)) continue;
194
203
  const r = el.getBoundingClientRect();
195
- if (r.height <= 0 || y < r.top || y > r.bottom) continue;
196
- // innerText 抓渲染后的正文;末尾的 HH:MM 是消息时间戳,去掉
197
- const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
198
- if (!text) continue;
199
- return { text, el };
204
+ if (r.height <= 0) continue;
205
+ if (!(el.innerText || "").trim()) continue;
206
+ // 视口坐标 + 已滚动量 = 在滚动内容里的坐标
207
+ const contentCenter = r.top - view.top + view.scrollTop + r.height / 2;
208
+ const y = Math.round(contentCenter / view.scrollHeight * window.innerHeight);
209
+ out.push({ y, el });
200
210
  }
201
- return null;
211
+ return out;
212
+ }
213
+
214
+ /** 取小地图纵坐标 y 附近(±TOL)的那条用户消息;没有就 null → 气泡收起。 */
215
+ function messageNearTick(y, ticks) {
216
+ const TOL = 16;
217
+ let best = null;
218
+ let bestD = Infinity;
219
+ for (const t of ticks) {
220
+ const d = Math.abs(t.y - y);
221
+ if (d <= TOL && d < bestD) { bestD = d; best = t; }
222
+ }
223
+ return best;
202
224
  }
203
225
 
204
226
  //#region settings
@@ -227,7 +249,8 @@ window.__ModuleLoader__.load({
227
249
  const prevRef = useRef(null);
228
250
  const roleRef = useRef(null);
229
251
  const textRef = useRef(null);
230
- const bubbleHoverRef = useRef(false);
252
+ const ticksRef = useRef(null);
253
+ const ticksDataRef = useRef([]); // [{y, el}] — 小地图刻度数据(showPreview 靠它找消息)
231
254
  const activeMsgRef = useRef(null);
232
255
 
233
256
  const setOn = useCallback((on) => {
@@ -251,6 +274,27 @@ window.__ModuleLoader__.load({
251
274
  let roEl = null;
252
275
  let mo = null;
253
276
  let moTimer = 0;
277
+ let tickTimer = 0;
278
+ /** 竖条上给「我的消息」打小地图刻度:不管滚到哪,自己的消息始终标在那。 */
279
+ const refreshTicks = () => {
280
+ const box = ticksRef.current;
281
+ const view = chatViewport();
282
+ const data = minimapTicks(view);
283
+ ticksDataRef.current = data;
284
+ if (!box) return;
285
+ while (box.children.length > data.length) box.removeChild(box.lastChild);
286
+ while (box.children.length < data.length) {
287
+ const i = document.createElement("i");
288
+ i.className = "dshscp_tick";
289
+ box.appendChild(i);
290
+ }
291
+ for (let i = 0; i < data.length; i++) box.children[i].style.top = data[i].y + "px";
292
+ };
293
+ // 对话增长 / 面板开合会改内容总长 → 节流到 150ms 重算(刻度与 scrollTop 无关,滚动不用重算)
294
+ const scheduleTicks = () => {
295
+ if (tickTimer) return;
296
+ tickTimer = window.setTimeout(() => { tickTimer = 0; refreshTicks(); }, 150);
297
+ };
254
298
  /** 把竖条 / 圆点 / 气泡钉到聊天区右边缘(右侧面板打开时跟着左移)。 */
255
299
  const placeAtChatEdge = () => {
256
300
  const view = chatViewport();
@@ -258,6 +302,8 @@ window.__ModuleLoader__.load({
258
302
  if (stripRef.current) stripRef.current.style.right = rightPx + "px";
259
303
  if (markerRef.current) markerRef.current.style.right = rightPx + "px";
260
304
  if (prevRef.current) prevRef.current.style.right = rightPx + 18 + "px";
305
+ if (ticksRef.current) ticksRef.current.style.right = rightPx + "px";
306
+ scheduleTicks();
261
307
  // 面板开合 / 窗口缩放会改聊天容器宽度 → 挂 ResizeObserver 跟着走
262
308
  const sc = findChatScroller();
263
309
  if (ro && sc && sc !== roEl) {
@@ -295,7 +341,11 @@ window.__ModuleLoader__.load({
295
341
  return;
296
342
  }
297
343
  activeMsgRef.current = msg;
298
- if (textRef.current) textRef.current.textContent = msg.text;
344
+ // 内容现取现算(消息可能被重新渲染);去掉末尾的 HH:MM 时间戳
345
+ if (textRef.current) {
346
+ textRef.current.textContent = (msg.el.innerText || "")
347
+ .replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
348
+ }
299
349
  el.style.pointerEvents = "auto";
300
350
  el.style.cursor = "pointer";
301
351
  // 竖直居中于鼠标,并夹在视口内
@@ -313,14 +363,22 @@ window.__ModuleLoader__.load({
313
363
  if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
314
364
  };
315
365
 
366
+ /** 鼠标是否正压在气泡上(压在气泡上时别收掉,用户要点它跳转)。 */
367
+ const overBubble = (x, y) => {
368
+ const el = prevRef.current;
369
+ if (!el || !el.classList.contains("dshscp_prevOn")) return false;
370
+ const r = el.getBoundingClientRect();
371
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
372
+ };
373
+
316
374
  const onMove = (e) => {
317
375
  const s = settingsRef.current;
318
376
  if (!s.on) return;
319
- if (bubbleHoverRef.current) return; // 鼠标停在气泡上(正要点击),别把它收掉
377
+ if (overBubble(e.clientX, e.clientY)) return; // 鼠标停在气泡上(正要点击)
320
378
  const view = placeAtChatEdge();
321
- if (!view) return;
322
- // 触发区 = 聊天区右边缘那一条;落在右侧面板上一律不响应
323
- if (e.clientX < view.right - s.stripWidth || e.clientX > view.right + 2) {
379
+ // 没有聊天区(欢迎页 / 新会话还没消息)→ 退回窗口右边缘,声音照出(踩过:这里 return 会让声音都没)
380
+ const baseRight = view ? view.right : window.innerWidth;
381
+ if (e.clientX < baseRight - s.stripWidth || e.clientX > baseRight + 2) {
324
382
  lastMidiRef.current = null;
325
383
  hidePreview();
326
384
  return;
@@ -330,8 +388,8 @@ window.__ModuleLoader__.load({
330
388
  // t=0(顶部)→ highNote;t=1(底部)→ lowNote
331
389
  const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
332
390
  showMarker(e.clientY);
333
- // 预览只看用户消息;没有就整个收起(不显示音名/MIDI)
334
- showPreview(e.clientY, userMessageAtY(e.clientY));
391
+ // 预览只挑「我发的消息」里离该刻度最近的那条(不显示音名/MIDI)
392
+ showPreview(e.clientY, messageNearTick(e.clientY, ticksDataRef.current));
335
393
  if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
336
394
  if (midi !== lastMidiRef.current) {
337
395
  lastMidiRef.current = midi;
@@ -345,11 +403,11 @@ window.__ModuleLoader__.load({
345
403
  };
346
404
  const onLeave = () => {
347
405
  lastMidiRef.current = null;
348
- bubbleHoverRef.current = false;
349
406
  hidePreview();
350
407
  };
351
408
  document.addEventListener("pointermove", onMove, { passive: true });
352
409
  document.addEventListener("pointerleave", onLeave);
410
+ // 不需要监听 scroll:刻度是按「内容偏移/内容总长」算的,与 scrollTop 无关
353
411
  window.addEventListener("blur", onLeave);
354
412
  window.addEventListener("resize", placeAtChatEdge);
355
413
  placeAtChatEdge();
@@ -361,25 +419,23 @@ window.__ModuleLoader__.load({
361
419
  if (ro) ro.disconnect();
362
420
  if (mo) mo.disconnect();
363
421
  if (moTimer) window.clearTimeout(moTimer);
422
+ if (tickTimer) window.clearTimeout(tickTimer);
364
423
  };
365
424
  }, []);
366
425
 
367
- // 竖条 + 位置圆点 + 「我的消息」预览气泡(气泡可点,点了跳过去)
426
+ // 竖条 + 「我的消息」刻度 + 位置圆点 + 预览气泡(气泡可点,点了跳过去)
368
427
  return h(React.Fragment, null,
369
428
  h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
429
+ h("div", { ref: ticksRef, className: "dshscp_ticks" }),
370
430
  h("div", { ref: markerRef, className: "dshscp_marker" }),
371
431
  h("div", {
372
432
  ref: prevRef,
373
433
  className: "dshscp_prev",
374
- // 鼠标移进气泡时别被 pointermove 收掉(要能点它跳转)
375
- onMouseEnter: () => { bubbleHoverRef.current = true; },
376
- onMouseLeave: () => { bubbleHoverRef.current = false; },
377
434
  onClick: () => {
378
435
  const m = activeMsgRef.current;
379
436
  if (!m || !m.el) return;
380
437
  try { m.el.scrollIntoView({ behavior: "smooth", block: "center" }); }
381
438
  catch { try { m.el.scrollIntoView(); } catch {} }
382
- bubbleHoverRef.current = false;
383
439
  activeMsgRef.current = null;
384
440
  if (prevRef.current) {
385
441
  prevRef.current.classList.remove("dshscp_prevOn");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scroll-piano",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
5
  "type": "module",
6
6
  "license": "MIT",