dsh-scroll-piano 0.3.0 → 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 +90 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,12 +6,13 @@
6
6
 
7
7
  - 聊天区右边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
8
8
  - 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
9
- - 气泡恒显当前音名(如 `♪ C4 · 60`);**当且只当鼠标正落在「你发的某条消息」上**时,气泡展开显示那条消息的正文(最多 3 行)+「点击跳转」。
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}",
@@ -37,7 +39,7 @@ window.__ModuleLoader__.load({
37
39
  ".dshscp_prevTop{display:flex;align-items:center;gap:8px;margin-bottom:4px}",
38
40
  ".dshscp_prevRole{flex:none;padding:1px 5px;border-radius:4px;background:rgba(91,141,239,.3);color:#bcd4ff;font-size:10px;line-height:14px}",
39
41
  ".dshscp_prevRoleAI{background:rgba(160,120,240,.28);color:#d6c4ff}",
40
- ".dshscp_prevNote{flex:none;color:#9aa2b1;font-size:10px;line-height:14px;font-variant-numeric:tabular-nums}",
42
+ ".dshscp_prevHint{margin-left:auto;flex:none;color:#9aa2b1;font-size:10px;line-height:14px}",
41
43
  ".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}"
42
44
  ].join("");
43
45
  const tagId = "dsh-scroll-piano/scroll-piano.css";
@@ -150,12 +152,7 @@ window.__ModuleLoader__.load({
150
152
  noise.start(now);
151
153
  }
152
154
 
153
- //#region note naming
154
- const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
155
- function noteLabel(midi) {
156
- return NOTE_NAMES[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1);
157
- }
158
-
155
+ //#region chat anchors
159
156
  /** 聊天区的滚动容器(从 `[data-chat-flow]` 往上找第一个可纵向滚动的祖先)。 */
160
157
  function findChatScroller() {
161
158
  const flow = document.querySelector("[data-chat-flow]");
@@ -182,28 +179,51 @@ window.__ModuleLoader__.load({
182
179
  }
183
180
 
184
181
  /**
185
- * 取**纵坐标 y 落在其范围内**的那条**用户消息**——只预览「我输入的」,
186
- * 不预览 AI 的。稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
182
+ * 取鼠标纵坐标附近的那条**用户消息**——只认「我输入的」,AI 回复一律不参与。
183
+ * 稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
187
184
  * `13:input-message<uuid>`(用户)/ `14:assistant-stepN:M`(AI)/
188
185
  * `12:turn-processN`(过程块),类名带 hash 所以只认 data 属性。
189
186
  *
190
- * 刻意**不做「取最近一条」兜底**:滑到 AI / 过程块上就返回 null(预览收起),
191
- * 否则同一条用户消息会在整片区域里被反复显示(就是「重复显示」)。
187
+ * **必须贴住消息本体**(超出 ±TOL 就算没有):不搞「取全局最近一条」,
188
+ * 否则在只有一个用户消息的视口里,鼠标滑到哪都显示同一条 = 重复显示。
189
+ * 位置感交给竖条上的刻度(`userMessageTicks`)。
192
190
  */
193
191
  function userMessageAtY(y) {
192
+ const TOL = 24; // 允许鼠标略微超出消息边界,滑动更宽容
194
193
  const flow = document.querySelector("[data-chat-flow]");
195
194
  if (!flow) return null;
195
+ let best = null;
196
+ let bestDist = Infinity;
196
197
  for (const el of flow.children) {
197
198
  const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
198
199
  if (!/input-message/.test(key)) continue;
199
200
  const r = el.getBoundingClientRect();
200
- 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;
201
204
  // innerText 抓渲染后的正文;末尾的 HH:MM 是消息时间戳,去掉
202
205
  const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
203
206
  if (!text) continue;
204
- return { text, el };
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 [];
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));
205
225
  }
206
- return null;
226
+ return out;
207
227
  }
208
228
 
209
229
  //#region settings
@@ -223,7 +243,6 @@ window.__ModuleLoader__.load({
223
243
  //#region component
224
244
  function ScrollPiano() {
225
245
  const [settings, setSettings] = useState(loadSettings);
226
- const [label, setLabel] = useState("");
227
246
  const settingsRef = useRef(settings);
228
247
  settingsRef.current = settings;
229
248
  const lastMidiRef = useRef(null);
@@ -232,9 +251,8 @@ window.__ModuleLoader__.load({
232
251
  const markerRef = useRef(null);
233
252
  const prevRef = useRef(null);
234
253
  const roleRef = useRef(null);
235
- const noteRef = useRef(null);
236
254
  const textRef = useRef(null);
237
- const bubbleHoverRef = useRef(false);
255
+ const ticksRef = useRef(null);
238
256
  const activeMsgRef = useRef(null);
239
257
 
240
258
  const setOn = useCallback((on) => {
@@ -258,6 +276,25 @@ window.__ModuleLoader__.load({
258
276
  let roEl = null;
259
277
  let mo = null;
260
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
+ };
261
298
  /** 把竖条 / 圆点 / 气泡钉到聊天区右边缘(右侧面板打开时跟着左移)。 */
262
299
  const placeAtChatEdge = () => {
263
300
  const view = chatViewport();
@@ -265,6 +302,8 @@ window.__ModuleLoader__.load({
265
302
  if (stripRef.current) stripRef.current.style.right = rightPx + "px";
266
303
  if (markerRef.current) markerRef.current.style.right = rightPx + "px";
267
304
  if (prevRef.current) prevRef.current.style.right = rightPx + 18 + "px";
305
+ if (ticksRef.current) ticksRef.current.style.right = rightPx + "px";
306
+ scheduleTicks();
268
307
  // 面板开合 / 窗口缩放会改聊天容器宽度 → 挂 ResizeObserver 跟着走
269
308
  const sc = findChatScroller();
270
309
  if (ro && sc && sc !== roEl) {
@@ -288,20 +327,28 @@ window.__ModuleLoader__.load({
288
327
  const m = markerRef.current;
289
328
  if (m) m.style.top = Math.round(y - 3.5) + "px";
290
329
  };
291
- const showPreview = (y, noteText, msg) => {
330
+ /**
331
+ * 气泡**只在鼠标压在某条「用户消息」上时出现**,内容只有那条消息的正文。
332
+ * 其余位置(AI 回复 / 工具块 / 空白)整个收起——不放音名、不放 MIDI 数字。
333
+ */
334
+ const showPreview = (y, msg) => {
292
335
  const el = prevRef.current;
293
336
  if (!el) return;
294
- activeMsgRef.current = msg || null;
295
- if (noteRef.current) noteRef.current.textContent = "♪ " + noteText + (msg ? " · 点击跳转" : "");
296
- if (roleRef.current) roleRef.current.style.display = msg ? "" : "none";
297
- if (textRef.current) textRef.current.textContent = msg ? msg.text : "";
298
- // 只有预览到用户消息时才可点(点了跳过去);否则纯视觉不拦事件
299
- el.style.pointerEvents = msg ? "auto" : "none";
300
- el.style.cursor = msg ? "pointer" : "default";
337
+ if (!msg) {
338
+ activeMsgRef.current = null;
339
+ el.classList.remove("dshscp_prevOn");
340
+ el.style.pointerEvents = "none";
341
+ return;
342
+ }
343
+ activeMsgRef.current = msg;
344
+ if (textRef.current) textRef.current.textContent = msg.text;
345
+ el.style.pointerEvents = "auto";
346
+ el.style.cursor = "pointer";
301
347
  // 竖直居中于鼠标,并夹在视口内
302
348
  const h = el.offsetHeight || 40;
303
349
  const top = Math.max(8, Math.min(window.innerHeight - h - 8, y - h / 2));
304
350
  el.style.top = Math.round(top) + "px";
351
+ el.classList.add("dshscp_prevOn");
305
352
  };
306
353
  const hidePreview = () => {
307
354
  activeMsgRef.current = null;
@@ -312,10 +359,18 @@ window.__ModuleLoader__.load({
312
359
  if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
313
360
  };
314
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
+
315
370
  const onMove = (e) => {
316
371
  const s = settingsRef.current;
317
372
  if (!s.on) return;
318
- if (bubbleHoverRef.current) return; // 鼠标停在气泡上(正要点击),别把它收掉
373
+ if (overBubble(e.clientX, e.clientY)) return; // 鼠标停在气泡上(正要点击)
319
374
  const view = placeAtChatEdge();
320
375
  if (!view) return;
321
376
  // 触发区 = 聊天区右边缘那一条;落在右侧面板上一律不响应
@@ -329,9 +384,9 @@ window.__ModuleLoader__.load({
329
384
  // t=0(顶部)→ highNote;t=1(底部)→ lowNote
330
385
  const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
331
386
  showMarker(e.clientY);
332
- showPreview(e.clientY, noteLabel(midi) + " · " + midi, userMessageAtY(e.clientY));
387
+ // 预览只挑「我发的消息」里离鼠标最近的那条(不显示音名/MIDI)
388
+ showPreview(e.clientY, userMessageAtY(e.clientY));
333
389
  if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
334
- if (prevRef.current) prevRef.current.classList.add("dshscp_prevOn");
335
390
  if (midi !== lastMidiRef.current) {
336
391
  lastMidiRef.current = midi;
337
392
  // 限速:滑太快时不要瞬间堆几十个音(会糊成一片杂音)
@@ -340,46 +395,44 @@ window.__ModuleLoader__.load({
340
395
  lastPlayRef.current = nowMs;
341
396
  playNote(midi, s.volume);
342
397
  }
343
- setLabel(noteLabel(midi));
344
398
  }
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");
@@ -390,7 +443,7 @@ window.__ModuleLoader__.load({
390
443
  },
391
444
  h("div", { className: "dshscp_prevTop" },
392
445
  h("span", { ref: roleRef, className: "dshscp_prevRole" }, "你"),
393
- h("span", { ref: noteRef, className: "dshscp_prevNote" }, "♪")
446
+ h("span", { className: "dshscp_prevHint" }, "点击跳转")
394
447
  ),
395
448
  h("div", { ref: textRef, className: "dshscp_prevText" })
396
449
  ),
@@ -411,8 +464,7 @@ window.__ModuleLoader__.load({
411
464
  value: settings.volume,
412
465
  onChange: (e) => setVolume(parseFloat(e.target.value)),
413
466
  title: "音量"
414
- }),
415
- label ? h("span", { style: { opacity: 0.75, minWidth: "26px" } }, label) : null
467
+ })
416
468
  )
417
469
  );
418
470
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scroll-piano",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
5
  "type": "module",
6
6
  "license": "MIT",