dsh-scroll-piano 0.2.1 → 0.3.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 (3) hide show
  1. package/README.md +14 -9
  2. package/lib/client.js +134 -56
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,30 +1,35 @@
1
1
  # dsh-scroll-piano · 滚动钢琴
2
2
 
3
- 鼠标滑过 DSH 界面**右侧边缘的竖条区域**(聊天区滚动条/把手那一条)时,按鼠标的**纵向位置**发出钢琴音:**越靠上音越高、越靠下音越低**(特雷门琴式)。
3
+ 鼠标滑过 DSH **聊天区右边缘的竖条**(滚动条/把手那一条)时,按鼠标的**纵向位置**发出钢琴音:**越靠上音越高、越靠下音越低**(特雷门琴式)。
4
4
 
5
5
  ## 效果
6
6
 
7
- - 右侧边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
7
+ - 聊天区右边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
8
8
  - 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
9
- - 鼠标旁浮出**该位置的消息预览气泡**(类似滚动条的预览 tooltip):角色标签(`你` / `AI`)+ 当前音名(如 `♪ C4 · 60`)+ 该高度那条消息的正文(最多 3 行)。
9
+ - **气泡只在鼠标正落在「你发的某条消息」上时出现**,内容就是那条消息的正文(最多 3 行)+「点击跳转」提示;**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
10
+ - **点击气泡** → 平滑滚动,把那条消息滚到视口中央。
10
11
  - 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
11
12
  - 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
12
13
 
14
+ > 预览**只显示用户自己发的消息**,且**不做「取最近一条」兜底**:滑到 AI 回复 / 工具调用 / 过程块上时气泡整个收起,避免同一条消息在整片区域里被反复显示。声音与预览**彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
15
+
13
16
  ## 消息预览怎么定位
14
17
 
15
18
  聊天流的稳定锚点是 **`[data-chat-flow]`**:它的**直接子元素**一条消息一个,key 形如:
16
19
 
17
20
  | key 前缀 | 含义 |
18
21
  |---|---|
19
- | `13:input-message<uuid>` | 用户消息 → 角色显示「你」 |
20
- | `14:assistant-stepN:M` | AI 消息 → 角色显示「AI」 |
21
- | `12:turn-processN` / `["process",…]` / `9:turn-tailN` | 过程 / 工具调用 / 轮次尾,**跳过**(此时显示离鼠标最近的那条消息,不弹占位文案) |
22
+ | `13:input-message<uuid>` | 用户消息 → **预览这条**(角色显示「你」) |
23
+ | `14:assistant-stepN:M` | AI 消息 → 跳过 |
24
+ | `12:turn-processN` / `["process",…]` / `9:turn-tailN` | 过程 / 工具调用 / 轮次尾 → 跳过 |
25
+
26
+ 滑动时按鼠标纵坐标 y 找**纵坐标落在其 `getBoundingClientRect()` 内**的那条用户消息,`innerText` 取正文(去掉末尾 `HH:MM` 时间戳);点击时对该元素 `scrollIntoView({ block: "center" })`。**只认这些 `data-*` 属性**——容器类名(`icaHSq_flowItem`、`ST7X_W_scrollBody`)都带 hash,不碰。
22
27
 
23
- 滑动时按鼠标纵坐标 y 找**包含 y 的那条**(落在过程/工具块上则取**距离最近的那条**消息),`getBoundingClientRect()` 实时判定、`innerText` 取正文(去掉末尾 `HH:MM` 时间戳),显示在气泡里。**只认这些 `data-*` 属性**——容器类名(`icaHSq_flowItem`、`ST7X_W_scrollBody`)都带 hash,不碰。
28
+ ## 触发区:聊天区右边缘,不是窗口右边缘
24
29
 
25
- ## 触发区为什么按坐标而不是抓 DOM
30
+ 滚动条本身是浏览器**原生滚动条**(不在 DOM 里、类名带 hash),所以触发用全局 `pointermove` + `clientX` 判定。但**判定基准是聊天区滚动容器的右边缘**(从 `[data-chat-flow]` 往上找第一个 `overflow-y:auto` 的祖先),不是窗口右边缘——否则右侧栏(文件预览等)打开时会在面板上触发、气泡还会盖住面板。
26
31
 
27
- 那根滚动条本身是浏览器**原生滚动条**——不在 DOM 里,周围容器类名还带 hash,抓不到也没法定制。所以插件用全局 `pointermove` + `clientX` 判定「是否落在窗口右侧竖条区间」、`clientY` 映射音高;只有**取消息预览**时才去读 `[data-chat-flow]` 的 `data-*` 稳定属性。
32
+ 竖条 / 圆点 / 气泡的位置也跟着这个右边缘走,用 `ResizeObserver`(容器变宽)+ `MutationObserver`(面板开合是 DOM 增删)双重兜底重算。
28
33
 
29
34
  ## 可调参数
30
35
 
package/lib/client.js CHANGED
@@ -37,7 +37,7 @@ window.__ModuleLoader__.load({
37
37
  ".dshscp_prevTop{display:flex;align-items:center;gap:8px;margin-bottom:4px}",
38
38
  ".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
39
  ".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}",
40
+ ".dshscp_prevHint{margin-left:auto;flex:none;color:#9aa2b1;font-size:10px;line-height:14px}",
41
41
  ".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
42
  ].join("");
43
43
  const tagId = "dsh-scroll-piano/scroll-piano.css";
@@ -150,44 +150,55 @@ window.__ModuleLoader__.load({
150
150
  noise.start(now);
151
151
  }
152
152
 
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);
153
+ //#region chat anchors
154
+ /** 聊天区的滚动容器(从 `[data-chat-flow]` 往上找第一个可纵向滚动的祖先)。 */
155
+ function findChatScroller() {
156
+ const flow = document.querySelector("[data-chat-flow]");
157
+ if (!flow) return null;
158
+ let sc = flow.parentElement;
159
+ while (sc && sc !== document.body) {
160
+ const cs = getComputedStyle(sc);
161
+ if (/(auto|scroll)/.test(cs.overflowY) && sc.clientHeight > 120) break;
162
+ sc = sc.parentElement;
163
+ }
164
+ return sc && sc !== document.body ? sc : flow;
165
+ }
166
+
167
+ /**
168
+ * 聊天区滚动容器的视口矩形——把触发区钉在**聊天区右边缘**,
169
+ * 而不是窗口右边缘:右侧栏(文件预览等)打开时,窗口右边是面板,
170
+ * 不该在面板上触发、更不该让气泡盖住面板。
171
+ */
172
+ function chatViewport() {
173
+ const el = findChatScroller();
174
+ if (!el) return null;
175
+ const r = el.getBoundingClientRect();
176
+ return { right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom) };
157
177
  }
158
178
 
159
179
  /**
160
- * 取当前视口里、纵坐标 y 处的那条消息(给右侧竖条滑动时做预览)。
180
+ * 取**纵坐标 y 落在其范围内**的那条**用户消息**——只预览「我输入的」,
181
+ * 不预览 AI 的。稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
182
+ * `13:input-message<uuid>`(用户)/ `14:assistant-stepN:M`(AI)/
183
+ * `12:turn-processN`(过程块),类名带 hash 所以只认 data 属性。
161
184
  *
162
- * 稳定锚点是 DSH 聊天流的 `[data-chat-flow]`:它的直接子元素每条消息一个,
163
- * key 形如 `13:input-message<uuid>`(用户)或 `14:assistant-stepN:M`(AI),
164
- * 还有 process / tool-call / turn-tail 这类非消息项。类名都带 hash(如
165
- * `icaHSq_flowItem`),所以这里**只认 data 属性,不碰 hash 类名**。
185
+ * 刻意**不做「取最近一条」兜底**:滑到 AI / 过程块上就返回 null(预览收起),
186
+ * 否则同一条用户消息会在整片区域里被反复显示(就是「重复显示」)。
166
187
  */
167
- function messageAtY(y) {
188
+ function userMessageAtY(y) {
168
189
  const flow = document.querySelector("[data-chat-flow]");
169
190
  if (!flow) return null;
170
- let best = null;
171
- let bestDist = Infinity;
172
191
  for (const el of flow.children) {
173
192
  const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
174
- let role = null;
175
- if (/input-message/.test(key)) role = "user";
176
- else if (/assistant/.test(key)) role = "ai";
177
- if (!role) continue; // process / tool / tail 之类不是消息,跳过
193
+ if (!/input-message/.test(key)) continue;
178
194
  const r = el.getBoundingClientRect();
179
- if (r.height <= 0) continue;
180
- // 落在本条内 dist=0;否则取离鼠标最近的一条——滑到工具/过程块上时
181
- // 显示的仍是它所属的那条消息,而不是弹一句「不是消息」
182
- const dist = y < r.top ? r.top - y : (y > r.bottom ? y - r.bottom : 0);
183
- if (dist >= bestDist) continue;
184
- // innerText 会把渲染出来的 Markdown 正文抓全;末尾的 HH:MM 是消息时间戳,去掉
195
+ if (r.height <= 0 || y < r.top || y > r.bottom) continue;
196
+ // innerText 抓渲染后的正文;末尾的 HH:MM 是消息时间戳,去掉
185
197
  const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
186
198
  if (!text) continue;
187
- bestDist = dist;
188
- best = { role, text };
199
+ return { text, el };
189
200
  }
190
- return best;
201
+ return null;
191
202
  }
192
203
 
193
204
  //#region settings
@@ -207,7 +218,6 @@ window.__ModuleLoader__.load({
207
218
  //#region component
208
219
  function ScrollPiano() {
209
220
  const [settings, setSettings] = useState(loadSettings);
210
- const [label, setLabel] = useState("");
211
221
  const settingsRef = useRef(settings);
212
222
  settingsRef.current = settings;
213
223
  const lastMidiRef = useRef(null);
@@ -216,8 +226,9 @@ window.__ModuleLoader__.load({
216
226
  const markerRef = useRef(null);
217
227
  const prevRef = useRef(null);
218
228
  const roleRef = useRef(null);
219
- const noteRef = useRef(null);
220
229
  const textRef = useRef(null);
230
+ const bubbleHoverRef = useRef(false);
231
+ const activeMsgRef = useRef(null);
221
232
 
222
233
  const setOn = useCallback((on) => {
223
234
  setSettings((s) => {
@@ -236,48 +247,92 @@ window.__ModuleLoader__.load({
236
247
  }, []);
237
248
 
238
249
  useEffect(() => {
250
+ let ro = null;
251
+ let roEl = null;
252
+ let mo = null;
253
+ let moTimer = 0;
254
+ /** 把竖条 / 圆点 / 气泡钉到聊天区右边缘(右侧面板打开时跟着左移)。 */
255
+ const placeAtChatEdge = () => {
256
+ const view = chatViewport();
257
+ const rightPx = view ? Math.max(0, window.innerWidth - view.right) : 0;
258
+ if (stripRef.current) stripRef.current.style.right = rightPx + "px";
259
+ if (markerRef.current) markerRef.current.style.right = rightPx + "px";
260
+ if (prevRef.current) prevRef.current.style.right = rightPx + 18 + "px";
261
+ // 面板开合 / 窗口缩放会改聊天容器宽度 → 挂 ResizeObserver 跟着走
262
+ const sc = findChatScroller();
263
+ if (ro && sc && sc !== roEl) {
264
+ if (roEl) ro.unobserve(roEl);
265
+ ro.observe(sc);
266
+ roEl = sc;
267
+ }
268
+ return view;
269
+ };
270
+ if (typeof ResizeObserver !== "undefined") ro = new ResizeObserver(() => placeAtChatEdge());
271
+ // 右侧面板开合是 DOM 增删(不一定触发 resize)→ 用 MutationObserver 兜底,
272
+ // 只监听 childList(不监听 attributes,避免自己写 style 造成回环)。
273
+ if (typeof MutationObserver !== "undefined") {
274
+ mo = new MutationObserver(() => {
275
+ if (moTimer) return;
276
+ moTimer = window.setTimeout(() => { moTimer = 0; placeAtChatEdge(); }, 200);
277
+ });
278
+ mo.observe(document.body, { childList: true, subtree: true });
279
+ }
239
280
  const showMarker = (y) => {
240
281
  const m = markerRef.current;
241
282
  if (m) m.style.top = Math.round(y - 3.5) + "px";
242
283
  };
243
- const showPreview = (y, noteText, msg) => {
284
+ /**
285
+ * 气泡**只在鼠标压在某条「用户消息」上时出现**,内容只有那条消息的正文。
286
+ * 其余位置(AI 回复 / 工具块 / 空白)整个收起——不放音名、不放 MIDI 数字。
287
+ */
288
+ const showPreview = (y, msg) => {
244
289
  const el = prevRef.current;
245
290
  if (!el) return;
246
- if (noteRef.current) noteRef.current.textContent = "♪ " + noteText;
247
- if (roleRef.current) {
248
- // 没有消息(空会话)时只留音名,不显示占位标签/文案
249
- roleRef.current.style.display = msg ? "" : "none";
250
- if (msg) {
251
- roleRef.current.textContent = msg.role === "user" ? "你" : "AI";
252
- roleRef.current.className = "dshscp_prevRole" + (msg.role === "ai" ? " dshscp_prevRoleAI" : "");
253
- }
291
+ if (!msg) {
292
+ activeMsgRef.current = null;
293
+ el.classList.remove("dshscp_prevOn");
294
+ el.style.pointerEvents = "none";
295
+ return;
254
296
  }
255
- if (textRef.current) textRef.current.textContent = msg ? msg.text : "";
297
+ activeMsgRef.current = msg;
298
+ if (textRef.current) textRef.current.textContent = msg.text;
299
+ el.style.pointerEvents = "auto";
300
+ el.style.cursor = "pointer";
256
301
  // 竖直居中于鼠标,并夹在视口内
257
- const h = el.offsetHeight || 56;
302
+ const h = el.offsetHeight || 40;
258
303
  const top = Math.max(8, Math.min(window.innerHeight - h - 8, y - h / 2));
259
304
  el.style.top = Math.round(top) + "px";
305
+ el.classList.add("dshscp_prevOn");
306
+ };
307
+ const hidePreview = () => {
308
+ activeMsgRef.current = null;
309
+ if (prevRef.current) {
310
+ prevRef.current.classList.remove("dshscp_prevOn");
311
+ prevRef.current.style.pointerEvents = "none";
312
+ }
313
+ if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
260
314
  };
261
315
 
262
316
  const onMove = (e) => {
263
317
  const s = settingsRef.current;
264
318
  if (!s.on) return;
265
- const W = window.innerWidth;
266
- const H = window.innerHeight;
267
- if (e.clientX < W - s.stripWidth) {
319
+ if (bubbleHoverRef.current) return; // 鼠标停在气泡上(正要点击),别把它收掉
320
+ const view = placeAtChatEdge();
321
+ if (!view) return;
322
+ // 触发区 = 聊天区右边缘那一条;落在右侧面板上一律不响应
323
+ if (e.clientX < view.right - s.stripWidth || e.clientX > view.right + 2) {
268
324
  lastMidiRef.current = null;
269
- if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
270
- if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
325
+ hidePreview();
271
326
  return;
272
327
  }
273
- const span = Math.max(1, H - s.inset * 2);
328
+ const span = Math.max(1, window.innerHeight - s.inset * 2);
274
329
  const t = Math.min(1, Math.max(0, (e.clientY - s.inset) / span));
275
330
  // t=0(顶部)→ highNote;t=1(底部)→ lowNote
276
331
  const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
277
332
  showMarker(e.clientY);
278
- showPreview(e.clientY, noteLabel(midi) + " · " + midi, messageAtY(e.clientY));
333
+ // 预览只看用户消息;没有就整个收起(不显示音名/MIDI)
334
+ showPreview(e.clientY, userMessageAtY(e.clientY));
279
335
  if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
280
- if (prevRef.current) prevRef.current.classList.add("dshscp_prevOn");
281
336
  if (midi !== lastMidiRef.current) {
282
337
  lastMidiRef.current = midi;
283
338
  // 限速:滑太快时不要瞬间堆几十个音(会糊成一片杂音)
@@ -286,32 +341,56 @@ window.__ModuleLoader__.load({
286
341
  lastPlayRef.current = nowMs;
287
342
  playNote(midi, s.volume);
288
343
  }
289
- setLabel(noteLabel(midi));
290
344
  }
291
345
  };
292
346
  const onLeave = () => {
293
347
  lastMidiRef.current = null;
294
- if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
295
- if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
348
+ bubbleHoverRef.current = false;
349
+ hidePreview();
296
350
  };
297
351
  document.addEventListener("pointermove", onMove, { passive: true });
298
352
  document.addEventListener("pointerleave", onLeave);
299
353
  window.addEventListener("blur", onLeave);
354
+ window.addEventListener("resize", placeAtChatEdge);
355
+ placeAtChatEdge();
300
356
  return () => {
301
357
  document.removeEventListener("pointermove", onMove);
302
358
  document.removeEventListener("pointerleave", onLeave);
303
359
  window.removeEventListener("blur", onLeave);
360
+ window.removeEventListener("resize", placeAtChatEdge);
361
+ if (ro) ro.disconnect();
362
+ if (mo) mo.disconnect();
363
+ if (moTimer) window.clearTimeout(moTimer);
304
364
  };
305
365
  }, []);
306
366
 
307
- // 竖条指示 + 位置圆点 + 「该位置消息预览」气泡(纯视觉,不拦事件)
367
+ // 竖条 + 位置圆点 + 「我的消息」预览气泡(气泡可点,点了跳过去)
308
368
  return h(React.Fragment, null,
309
369
  h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
310
370
  h("div", { ref: markerRef, className: "dshscp_marker" }),
311
- h("div", { ref: prevRef, className: "dshscp_prev" },
371
+ h("div", {
372
+ ref: prevRef,
373
+ className: "dshscp_prev",
374
+ // 鼠标移进气泡时别被 pointermove 收掉(要能点它跳转)
375
+ onMouseEnter: () => { bubbleHoverRef.current = true; },
376
+ onMouseLeave: () => { bubbleHoverRef.current = false; },
377
+ onClick: () => {
378
+ const m = activeMsgRef.current;
379
+ if (!m || !m.el) return;
380
+ try { m.el.scrollIntoView({ behavior: "smooth", block: "center" }); }
381
+ catch { try { m.el.scrollIntoView(); } catch {} }
382
+ bubbleHoverRef.current = false;
383
+ activeMsgRef.current = null;
384
+ if (prevRef.current) {
385
+ prevRef.current.classList.remove("dshscp_prevOn");
386
+ prevRef.current.style.pointerEvents = "none";
387
+ }
388
+ if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
389
+ }
390
+ },
312
391
  h("div", { className: "dshscp_prevTop" },
313
- h("span", { ref: roleRef, className: "dshscp_prevRole" }, "—"),
314
- h("span", { ref: noteRef, className: "dshscp_prevNote" }, "♪")
392
+ h("span", { ref: roleRef, className: "dshscp_prevRole" }, "你"),
393
+ h("span", { className: "dshscp_prevHint" }, "点击跳转")
315
394
  ),
316
395
  h("div", { ref: textRef, className: "dshscp_prevText" })
317
396
  ),
@@ -332,8 +411,7 @@ window.__ModuleLoader__.load({
332
411
  value: settings.volume,
333
412
  onChange: (e) => setVolume(parseFloat(e.target.value)),
334
413
  title: "音量"
335
- }),
336
- label ? h("span", { style: { opacity: 0.75, minWidth: "26px" } }, label) : null
414
+ })
337
415
  )
338
416
  );
339
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scroll-piano",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
5
  "type": "module",
6
6
  "license": "MIT",