dsh-scroll-piano 0.3.2 → 0.3.4

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 +6 -5
  2. package/lib/client.js +90 -69
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,13 +6,14 @@
6
6
 
7
7
  - 聊天区右边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
8
8
  - 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
9
- - 竖条上有**「我的消息」刻度**:你每发一条消息,对应高度就有一根蓝色小横杠——一眼看出自己发的话都在哪。
10
- - **气泡只在鼠标贴住「你发的某条消息」时出现**(±24px 容差),内容就是那条消息的正文(最多 3 行)+「点击跳转」提示;**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
11
- - **点击气泡** → 平滑滚动,把那条消息滚到视口中央。
9
+ - 竖条上有**「我的消息」小地图刻度**:你每发一条消息,对应位置一根蓝色小横杠——**按「在整段对话里的位置」定位,与滚动无关**,所以不管你滚到对话多深,自己的消息始终标在那、点得到。
10
+ - **气泡只在鼠标贴近某条刻度(±16px)时出现**,内容就是那条消息的正文(最多 3 行);**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
11
+ - **点蓝色小横杠 → 跳到那条消息**(平滑滚动、滚到视口中央)。蓝条命中区放大到 18×12px(可视化仍是 7×2 细条,悬停时加宽变亮),比原来 2px 高好点得多;**点气泡也能跳**(两者同一个 `jumpTo`)。
12
12
  - 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
13
13
  - 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
14
14
 
15
- > 预览**只显示用户自己发的消息**,且**不做「取全局最近一条」兜底**:鼠标离你所有消息都超过 ±24px 时气泡整个收起——否则在一个只有一条用户消息的视口里,滑到哪都显示同一条(就是「重复显示」)。位置感交给竖条刻度。声音与预览**彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
15
+ > 预览**只显示用户自己发的消息**,**不做「取全局最近一条」兜底**(否则同一条会在整片区域反复出现 = 重复显示),位置感交给刻度。**声音与预览彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
16
+ > 拿不到聊天区时(欢迎页 / 新会话还没有消息),触发区**退回窗口右边缘**,声音照出——否则那类页面连声音都没有(踩过)。
16
17
 
17
18
  ## 消息预览怎么定位
18
19
 
@@ -24,7 +25,7 @@
24
25
  | `14:assistant-stepN:M` | AI 消息 → 跳过 |
25
26
  | `12:turn-processN` / `["process",…]` / `9:turn-tailN` | 过程 / 工具调用 / 轮次尾 → 跳过 |
26
27
 
27
- 滑动时按鼠标纵坐标 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` 时间戳)显示;**点那根蓝条**(容器 `click` 事件委托到 `.dshscp_tick`,读 `data-i` 找回消息元素)就 `scrollIntoView({ block: "center" })`。**只认这些 `data-*` 属性**——容器类名(`icaHSq_flowItem`、`ST7X_W_scrollBody`)都带 hash,不碰。
28
29
 
29
30
  ## 触发区:聊天区右边缘,不是窗口右边缘
30
31
 
package/lib/client.js CHANGED
@@ -30,8 +30,10 @@ 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
+ ".dshscp_ticks{position:fixed;top:0;right:0;width:20px;height:100%;z-index:2147482000;pointer-events:none}",
34
+ ".dshscp_tick{position:absolute;right:0;width:18px;height:12px;margin-top:-6px;pointer-events:auto;cursor:pointer}",
35
+ ".dshscp_tick::after{content:\"\";position:absolute;right:0;top:50%;transform:translateY(-50%);width:7px;height:2px;border-radius:1px;background:rgba(91,141,239,.95);box-shadow:0 0 4px rgba(91,141,239,.7);transition:all .12s ease}",
36
+ ".dshscp_tick:hover::after{width:16px;box-shadow:0 0 7px rgba(91,141,239,1)}",
35
37
  ".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}",
36
38
  ".dshscp_markerOn{opacity:1}",
37
39
  ".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}",
@@ -39,7 +41,6 @@ window.__ModuleLoader__.load({
39
41
  ".dshscp_prevTop{display:flex;align-items:center;gap:8px;margin-bottom:4px}",
40
42
  ".dshscp_prevRole{flex:none;padding:1px 5px;border-radius:4px;background:rgba(91,141,239,.3);color:#bcd4ff;font-size:10px;line-height:14px}",
41
43
  ".dshscp_prevRoleAI{background:rgba(160,120,240,.28);color:#d6c4ff}",
42
- ".dshscp_prevHint{margin-left:auto;flex:none;color:#9aa2b1;font-size:10px;line-height:14px}",
43
44
  ".dshscp_prevText{font-size:12px;line-height:17px;color:#e6e8ee;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;word-break:break-word}"
44
45
  ].join("");
45
46
  const tagId = "dsh-scroll-piano/scroll-piano.css";
@@ -167,53 +168,35 @@ window.__ModuleLoader__.load({
167
168
  }
168
169
 
169
170
  /**
170
- * 聊天区滚动容器的视口矩形——把触发区钉在**聊天区右边缘**,
171
- * 而不是窗口右边缘:右侧栏(文件预览等)打开时,窗口右边是面板,
172
- * 不该在面板上触发、更不该让气泡盖住面板。
171
+ * 聊天区滚动容器的度量。`right` 用来把触发区钉在聊天区右边缘(右侧栏打开时不盖面板);
172
+ * `top/scrollTop/scrollHeight` 用来把**整段对话**压进竖条做小地图。
173
+ *
174
+ * 拿不到聊天区(欢迎页 / 新会话还没消息)时返回 null——调用方**退回窗口右边缘**,
175
+ * 否则那类页面连钢琴声都不出(踩过)。
173
176
  */
174
177
  function chatViewport() {
175
178
  const el = findChatScroller();
176
179
  if (!el) return null;
177
180
  const r = el.getBoundingClientRect();
178
- return { right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom) };
181
+ return {
182
+ right: Math.round(r.right),
183
+ top: Math.round(r.top),
184
+ scrollTop: el.scrollTop,
185
+ scrollHeight: Math.max(1, el.scrollHeight),
186
+ clientHeight: Math.max(1, el.clientHeight)
187
+ };
179
188
  }
180
189
 
181
190
  /**
182
- * 取鼠标纵坐标附近的那条**用户消息**——只认「我输入的」,AI 回复一律不参与。
183
- * 稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
184
- * `13:input-message<uuid>`(用户)/ `14:assistant-stepN:M`(AI)/
185
- * `12:turn-processN`(过程块),类名带 hash 所以只认 data 属性。
191
+ * 把**整段对话**映射成竖条上的小地图刻度:每条用户消息一个 y(0..视口高)。
186
192
  *
187
- * **必须贴住消息本体**(超出 ±TOL 就算没有):不搞「取全局最近一条」,
188
- * 否则在只有一个用户消息的视口里,鼠标滑到哪都显示同一条 = 重复显示。
189
- * 位置感交给竖条上的刻度(`userMessageTicks`)。
193
+ * y 取的是「消息在**滚动内容**里的偏移 / 内容总长」,**与 scrollTop 无关**——
194
+ * 所以不管滚到哪、滚多远,自己的消息刻度**始终在那**,看得见也点得到。
195
+ * (上一版按视口坐标定位:滚到对话底部时你的消息 y 是 -4339,全跑到屏幕外了。)
190
196
  */
191
- function userMessageAtY(y) {
192
- const TOL = 24; // 允许鼠标略微超出消息边界,滑动更宽容
197
+ function minimapTicks(view) {
193
198
  const flow = document.querySelector("[data-chat-flow]");
194
- if (!flow) return null;
195
- let best = null;
196
- let bestDist = Infinity;
197
- for (const el of flow.children) {
198
- const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
199
- if (!/input-message/.test(key)) continue;
200
- const r = el.getBoundingClientRect();
201
- if (r.height <= 0) continue;
202
- const dist = y < r.top ? r.top - y : (y > r.bottom ? y - r.bottom : 0);
203
- if (dist > TOL || dist >= bestDist) continue;
204
- // innerText 抓渲染后的正文;末尾的 HH:MM 是消息时间戳,去掉
205
- const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
206
- if (!text) continue;
207
- bestDist = dist;
208
- best = { text, el, dist };
209
- }
210
- return best;
211
- }
212
-
213
- /** 列出当前所有用户消息在视口里的纵向中心(给竖条上的刻度用)。 */
214
- function userMessageTicks() {
215
- const flow = document.querySelector("[data-chat-flow]");
216
- if (!flow) return [];
199
+ if (!flow || !view) return [];
217
200
  const out = [];
218
201
  for (const el of flow.children) {
219
202
  const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
@@ -221,11 +204,26 @@ window.__ModuleLoader__.load({
221
204
  const r = el.getBoundingClientRect();
222
205
  if (r.height <= 0) continue;
223
206
  if (!(el.innerText || "").trim()) continue;
224
- out.push(Math.round(r.top + r.height / 2));
207
+ // 视口坐标 + 已滚动量 = 在滚动内容里的坐标
208
+ const contentCenter = r.top - view.top + view.scrollTop + r.height / 2;
209
+ const y = Math.round(contentCenter / view.scrollHeight * window.innerHeight);
210
+ out.push({ y, el });
225
211
  }
226
212
  return out;
227
213
  }
228
214
 
215
+ /** 取小地图纵坐标 y 附近(±TOL)的那条用户消息;没有就 null → 气泡收起。 */
216
+ function messageNearTick(y, ticks) {
217
+ const TOL = 16;
218
+ let best = null;
219
+ let bestD = Infinity;
220
+ for (const t of ticks) {
221
+ const d = Math.abs(t.y - y);
222
+ if (d <= TOL && d < bestD) { bestD = d; best = t; }
223
+ }
224
+ return best;
225
+ }
226
+
229
227
  //#region settings
230
228
  const LS_KEY = "dsh-scroll-piano:settings";
231
229
  const DEFAULTS = { on: true, volume: 0.25, stripWidth: 30, lowNote: 48, highNote: 84, inset: 56 };
@@ -253,6 +251,8 @@ window.__ModuleLoader__.load({
253
251
  const roleRef = useRef(null);
254
252
  const textRef = useRef(null);
255
253
  const ticksRef = useRef(null);
254
+ const ticksDataRef = useRef([]); // [{y, el}] — 小地图刻度数据(showPreview 靠它找消息)
255
+ const jumpToRef = useRef(() => {});
256
256
  const activeMsgRef = useRef(null);
257
257
 
258
258
  const setOn = useCallback((on) => {
@@ -277,20 +277,45 @@ window.__ModuleLoader__.load({
277
277
  let mo = null;
278
278
  let moTimer = 0;
279
279
  let tickTimer = 0;
280
- /** 竖条上给「我的消息」打刻度:一眼看出自己发的话都在哪。 */
280
+ /** 平滑滚动到某条消息(点蓝条 / 点气泡都走这里)。 */
281
+ const jumpTo = (el) => {
282
+ if (!el) return;
283
+ try { el.scrollIntoView({ behavior: "smooth", block: "center" }); }
284
+ catch { try { el.scrollIntoView(); } catch {} }
285
+ activeMsgRef.current = null;
286
+ if (prevRef.current) {
287
+ prevRef.current.classList.remove("dshscp_prevOn");
288
+ prevRef.current.style.pointerEvents = "none";
289
+ }
290
+ if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
291
+ };
292
+ jumpToRef.current = jumpTo;
293
+ /** 点竖条上的蓝色小横杠 → 跳到那条消息(事件委托,刻度是动态增删的)。 */
294
+ const onTickClick = (e) => {
295
+ const t = e.target;
296
+ if (!t || !t.classList || !t.classList.contains("dshscp_tick")) return;
297
+ const idx = parseInt(t.getAttribute("data-i"), 10);
298
+ if (idx >= 0) jumpTo(ticksDataRef.current[idx] && ticksDataRef.current[idx].el);
299
+ };
300
+ /** 竖条上给「我的消息」打小地图刻度:不管滚到哪,自己的消息始终标在那。 */
281
301
  const refreshTicks = () => {
282
302
  const box = ticksRef.current;
303
+ const view = chatViewport();
304
+ const data = minimapTicks(view);
305
+ ticksDataRef.current = data;
283
306
  if (!box) return;
284
- const ys = userMessageTicks();
285
- while (box.children.length > ys.length) box.removeChild(box.lastChild);
286
- while (box.children.length < ys.length) {
307
+ while (box.children.length > data.length) box.removeChild(box.lastChild);
308
+ while (box.children.length < data.length) {
287
309
  const i = document.createElement("i");
288
310
  i.className = "dshscp_tick";
289
311
  box.appendChild(i);
290
312
  }
291
- for (let i = 0; i < ys.length; i++) box.children[i].style.top = Math.round(ys[i]) + "px";
313
+ for (let i = 0; i < data.length; i++) {
314
+ box.children[i].style.top = data[i].y + "px";
315
+ box.children[i].setAttribute("data-i", String(i));
316
+ }
292
317
  };
293
- // 滚动 / 流式更新会让刻度移位;节流到 150ms,避免 pointermove 上做布局读取
318
+ // 对话增长 / 面板开合会改内容总长 → 节流到 150ms 重算(刻度与 scrollTop 无关,滚动不用重算)
294
319
  const scheduleTicks = () => {
295
320
  if (tickTimer) return;
296
321
  tickTimer = window.setTimeout(() => { tickTimer = 0; refreshTicks(); }, 150);
@@ -341,7 +366,11 @@ window.__ModuleLoader__.load({
341
366
  return;
342
367
  }
343
368
  activeMsgRef.current = msg;
344
- if (textRef.current) textRef.current.textContent = msg.text;
369
+ // 内容现取现算(消息可能被重新渲染);去掉末尾的 HH:MM 时间戳
370
+ if (textRef.current) {
371
+ textRef.current.textContent = (msg.el.innerText || "")
372
+ .replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
373
+ }
345
374
  el.style.pointerEvents = "auto";
346
375
  el.style.cursor = "pointer";
347
376
  // 竖直居中于鼠标,并夹在视口内
@@ -372,9 +401,9 @@ window.__ModuleLoader__.load({
372
401
  if (!s.on) return;
373
402
  if (overBubble(e.clientX, e.clientY)) return; // 鼠标停在气泡上(正要点击)
374
403
  const view = placeAtChatEdge();
375
- if (!view) return;
376
- // 触发区 = 聊天区右边缘那一条;落在右侧面板上一律不响应
377
- if (e.clientX < view.right - s.stripWidth || e.clientX > view.right + 2) {
404
+ // 没有聊天区(欢迎页 / 新会话还没消息)→ 退回窗口右边缘,声音照出(踩过:这里 return 会让声音都没)
405
+ const baseRight = view ? view.right : window.innerWidth;
406
+ if (e.clientX < baseRight - s.stripWidth || e.clientX > baseRight + 2) {
378
407
  lastMidiRef.current = null;
379
408
  hidePreview();
380
409
  return;
@@ -384,8 +413,8 @@ window.__ModuleLoader__.load({
384
413
  // t=0(顶部)→ highNote;t=1(底部)→ lowNote
385
414
  const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
386
415
  showMarker(e.clientY);
387
- // 预览只挑「我发的消息」里离鼠标最近的那条(不显示音名/MIDI)
388
- showPreview(e.clientY, userMessageAtY(e.clientY));
416
+ // 预览只挑「我发的消息」里离该刻度最近的那条(不显示音名/MIDI)
417
+ showPreview(e.clientY, messageNearTick(e.clientY, ticksDataRef.current));
389
418
  if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
390
419
  if (midi !== lastMidiRef.current) {
391
420
  lastMidiRef.current = midi;
@@ -403,14 +432,15 @@ window.__ModuleLoader__.load({
403
432
  };
404
433
  document.addEventListener("pointermove", onMove, { passive: true });
405
434
  document.addEventListener("pointerleave", onLeave);
406
- document.addEventListener("scroll", scheduleTicks, { passive: true, capture: true });
435
+ // 不需要监听 scroll:刻度是按「内容偏移/内容总长」算的,与 scrollTop 无关
436
+ if (ticksRef.current) ticksRef.current.addEventListener("click", onTickClick);
407
437
  window.addEventListener("blur", onLeave);
408
438
  window.addEventListener("resize", placeAtChatEdge);
409
439
  placeAtChatEdge();
410
440
  return () => {
411
441
  document.removeEventListener("pointermove", onMove);
412
442
  document.removeEventListener("pointerleave", onLeave);
413
- document.removeEventListener("scroll", scheduleTicks, { capture: true });
443
+ if (ticksRef.current) ticksRef.current.removeEventListener("click", onTickClick);
414
444
  window.removeEventListener("blur", onLeave);
415
445
  window.removeEventListener("resize", placeAtChatEdge);
416
446
  if (ro) ro.disconnect();
@@ -420,7 +450,10 @@ window.__ModuleLoader__.load({
420
450
  };
421
451
  }, []);
422
452
 
423
- // 竖条 + 「我的消息」刻度 + 位置圆点 + 预览气泡(气泡可点,点了跳过去)
453
+ /** 点气泡也跳(文字提示已去掉,主入口是点蓝条)。 */
454
+ const onBubbleClick = () => jumpToRef.current(activeMsgRef.current && activeMsgRef.current.el);
455
+
456
+ // 竖条 + 「我的消息」刻度(**点蓝色小横杠跳过去**)+ 位置圆点 + 预览气泡
424
457
  return h(React.Fragment, null,
425
458
  h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
426
459
  h("div", { ref: ticksRef, className: "dshscp_ticks" }),
@@ -428,22 +461,10 @@ window.__ModuleLoader__.load({
428
461
  h("div", {
429
462
  ref: prevRef,
430
463
  className: "dshscp_prev",
431
- onClick: () => {
432
- const m = activeMsgRef.current;
433
- if (!m || !m.el) return;
434
- try { m.el.scrollIntoView({ behavior: "smooth", block: "center" }); }
435
- catch { try { m.el.scrollIntoView(); } catch {} }
436
- activeMsgRef.current = null;
437
- if (prevRef.current) {
438
- prevRef.current.classList.remove("dshscp_prevOn");
439
- prevRef.current.style.pointerEvents = "none";
440
- }
441
- if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
442
- }
464
+ onClick: onBubbleClick
443
465
  },
444
466
  h("div", { className: "dshscp_prevTop" },
445
- h("span", { ref: roleRef, className: "dshscp_prevRole" }, "你"),
446
- h("span", { className: "dshscp_prevHint" }, "点击跳转")
467
+ h("span", { ref: roleRef, className: "dshscp_prevRole" }, "你")
447
468
  ),
448
469
  h("div", { ref: textRef, className: "dshscp_prevText" })
449
470
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scroll-piano",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
5
  "type": "module",
6
6
  "license": "MIT",