dsh-scroll-piano 0.3.1 → 0.3.2

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 +3 -2
  2. package/lib/client.js +69 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,12 +6,13 @@
6
6
 
7
7
  - 聊天区右边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
8
8
  - 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
9
- - **气泡只在鼠标正落在「你发的某条消息」上时出现**,内容就是那条消息的正文(最多 3 行)+「点击跳转」提示;**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
9
+ - 竖条上有**「我的消息」刻度**:你每发一条消息,对应高度就有一根蓝色小横杠——一眼看出自己发的话都在哪。
10
+ - **气泡只在鼠标贴住「你发的某条消息」时出现**(±24px 容差),内容就是那条消息的正文(最多 3 行)+「点击跳转」提示;**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
10
11
  - **点击气泡** → 平滑滚动,把那条消息滚到视口中央。
11
12
  - 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
12
13
  - 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
13
14
 
14
- > 预览**只显示用户自己发的消息**,且**不做「取最近一条」兜底**:滑到 AI 回复 / 工具调用 / 过程块上时气泡整个收起,避免同一条消息在整片区域里被反复显示。声音与预览**彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
15
+ > 预览**只显示用户自己发的消息**,且**不做「取全局最近一条」兜底**:鼠标离你所有消息都超过 ±24px 时气泡整个收起——否则在一个只有一条用户消息的视口里,滑到哪都显示同一条(就是「重复显示」)。位置感交给竖条刻度。声音与预览**彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
15
16
 
16
17
  ## 消息预览怎么定位
17
18
 
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}",
@@ -177,28 +179,51 @@ window.__ModuleLoader__.load({
177
179
  }
178
180
 
179
181
  /**
180
- * 取**纵坐标 y 落在其范围内**的那条**用户消息**——只预览「我输入的」,
181
- * 不预览 AI 的。稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
182
+ * 取鼠标纵坐标附近的那条**用户消息**——只认「我输入的」,AI 回复一律不参与。
183
+ * 稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
182
184
  * `13:input-message<uuid>`(用户)/ `14:assistant-stepN:M`(AI)/
183
185
  * `12:turn-processN`(过程块),类名带 hash 所以只认 data 属性。
184
186
  *
185
- * 刻意**不做「取最近一条」兜底**:滑到 AI / 过程块上就返回 null(预览收起),
186
- * 否则同一条用户消息会在整片区域里被反复显示(就是「重复显示」)。
187
+ * **必须贴住消息本体**(超出 ±TOL 就算没有):不搞「取全局最近一条」,
188
+ * 否则在只有一个用户消息的视口里,鼠标滑到哪都显示同一条 = 重复显示。
189
+ * 位置感交给竖条上的刻度(`userMessageTicks`)。
187
190
  */
188
191
  function userMessageAtY(y) {
192
+ const TOL = 24; // 允许鼠标略微超出消息边界,滑动更宽容
189
193
  const flow = document.querySelector("[data-chat-flow]");
190
194
  if (!flow) return null;
195
+ let best = null;
196
+ let bestDist = Infinity;
191
197
  for (const el of flow.children) {
192
198
  const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
193
199
  if (!/input-message/.test(key)) continue;
194
200
  const r = el.getBoundingClientRect();
195
- if (r.height <= 0 || y < r.top || y > r.bottom) continue;
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;
196
204
  // innerText 抓渲染后的正文;末尾的 HH:MM 是消息时间戳,去掉
197
205
  const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
198
206
  if (!text) continue;
199
- return { text, el };
207
+ bestDist = dist;
208
+ best = { text, el, dist };
200
209
  }
201
- return null;
210
+ return best;
211
+ }
212
+
213
+ /** 列出当前所有用户消息在视口里的纵向中心(给竖条上的刻度用)。 */
214
+ function userMessageTicks() {
215
+ const flow = document.querySelector("[data-chat-flow]");
216
+ if (!flow) return [];
217
+ const out = [];
218
+ for (const el of flow.children) {
219
+ const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
220
+ if (!/input-message/.test(key)) continue;
221
+ const r = el.getBoundingClientRect();
222
+ if (r.height <= 0) continue;
223
+ if (!(el.innerText || "").trim()) continue;
224
+ out.push(Math.round(r.top + r.height / 2));
225
+ }
226
+ return out;
202
227
  }
203
228
 
204
229
  //#region settings
@@ -227,7 +252,7 @@ window.__ModuleLoader__.load({
227
252
  const prevRef = useRef(null);
228
253
  const roleRef = useRef(null);
229
254
  const textRef = useRef(null);
230
- const bubbleHoverRef = useRef(false);
255
+ const ticksRef = useRef(null);
231
256
  const activeMsgRef = useRef(null);
232
257
 
233
258
  const setOn = useCallback((on) => {
@@ -251,6 +276,25 @@ window.__ModuleLoader__.load({
251
276
  let roEl = null;
252
277
  let mo = null;
253
278
  let moTimer = 0;
279
+ let tickTimer = 0;
280
+ /** 竖条上给「我的消息」打刻度:一眼看出自己发的话都在哪。 */
281
+ const refreshTicks = () => {
282
+ const box = ticksRef.current;
283
+ 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) {
287
+ const i = document.createElement("i");
288
+ i.className = "dshscp_tick";
289
+ box.appendChild(i);
290
+ }
291
+ for (let i = 0; i < ys.length; i++) box.children[i].style.top = Math.round(ys[i]) + "px";
292
+ };
293
+ // 滚动 / 流式更新会让刻度移位;节流到 150ms,避免 pointermove 上做布局读取
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) {
@@ -313,10 +359,18 @@ window.__ModuleLoader__.load({
313
359
  if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
314
360
  };
315
361
 
362
+ /** 鼠标是否正压在气泡上(压在气泡上时别收掉,用户要点它跳转)。 */
363
+ const overBubble = (x, y) => {
364
+ const el = prevRef.current;
365
+ if (!el || !el.classList.contains("dshscp_prevOn")) return false;
366
+ const r = el.getBoundingClientRect();
367
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
368
+ };
369
+
316
370
  const onMove = (e) => {
317
371
  const s = settingsRef.current;
318
372
  if (!s.on) return;
319
- if (bubbleHoverRef.current) return; // 鼠标停在气泡上(正要点击),别把它收掉
373
+ if (overBubble(e.clientX, e.clientY)) return; // 鼠标停在气泡上(正要点击)
320
374
  const view = placeAtChatEdge();
321
375
  if (!view) return;
322
376
  // 触发区 = 聊天区右边缘那一条;落在右侧面板上一律不响应
@@ -330,7 +384,7 @@ window.__ModuleLoader__.load({
330
384
  // t=0(顶部)→ highNote;t=1(底部)→ lowNote
331
385
  const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
332
386
  showMarker(e.clientY);
333
- // 预览只看用户消息;没有就整个收起(不显示音名/MIDI)
387
+ // 预览只挑「我发的消息」里离鼠标最近的那条(不显示音名/MIDI)
334
388
  showPreview(e.clientY, userMessageAtY(e.clientY));
335
389
  if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
336
390
  if (midi !== lastMidiRef.current) {
@@ -345,41 +399,40 @@ window.__ModuleLoader__.load({
345
399
  };
346
400
  const onLeave = () => {
347
401
  lastMidiRef.current = null;
348
- bubbleHoverRef.current = false;
349
402
  hidePreview();
350
403
  };
351
404
  document.addEventListener("pointermove", onMove, { passive: true });
352
405
  document.addEventListener("pointerleave", onLeave);
406
+ document.addEventListener("scroll", scheduleTicks, { passive: true, capture: true });
353
407
  window.addEventListener("blur", onLeave);
354
408
  window.addEventListener("resize", placeAtChatEdge);
355
409
  placeAtChatEdge();
356
410
  return () => {
357
411
  document.removeEventListener("pointermove", onMove);
358
412
  document.removeEventListener("pointerleave", onLeave);
413
+ document.removeEventListener("scroll", scheduleTicks, { capture: true });
359
414
  window.removeEventListener("blur", onLeave);
360
415
  window.removeEventListener("resize", placeAtChatEdge);
361
416
  if (ro) ro.disconnect();
362
417
  if (mo) mo.disconnect();
363
418
  if (moTimer) window.clearTimeout(moTimer);
419
+ if (tickTimer) window.clearTimeout(tickTimer);
364
420
  };
365
421
  }, []);
366
422
 
367
- // 竖条 + 位置圆点 + 「我的消息」预览气泡(气泡可点,点了跳过去)
423
+ // 竖条 + 「我的消息」刻度 + 位置圆点 + 预览气泡(气泡可点,点了跳过去)
368
424
  return h(React.Fragment, null,
369
425
  h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
426
+ h("div", { ref: ticksRef, className: "dshscp_ticks" }),
370
427
  h("div", { ref: markerRef, className: "dshscp_marker" }),
371
428
  h("div", {
372
429
  ref: prevRef,
373
430
  className: "dshscp_prev",
374
- // 鼠标移进气泡时别被 pointermove 收掉(要能点它跳转)
375
- onMouseEnter: () => { bubbleHoverRef.current = true; },
376
- onMouseLeave: () => { bubbleHoverRef.current = false; },
377
431
  onClick: () => {
378
432
  const m = activeMsgRef.current;
379
433
  if (!m || !m.el) return;
380
434
  try { m.el.scrollIntoView({ behavior: "smooth", block: "center" }); }
381
435
  catch { try { m.el.scrollIntoView(); } catch {} }
382
- bubbleHoverRef.current = false;
383
436
  activeMsgRef.current = null;
384
437
  if (prevRef.current) {
385
438
  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.2",
4
4
  "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
5
  "type": "module",
6
6
  "license": "MIT",