dsh-scroll-piano 0.2.1 → 0.3.0

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 +122 -43
  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
+ - 气泡恒显当前音名(如 `♪ C4 · 60`);**当且只当鼠标正落在「你发的某条消息」上**时,气泡展开显示那条消息的正文(最多 3 行)+「点击跳转」。
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
@@ -156,38 +156,54 @@ window.__ModuleLoader__.load({
156
156
  return NOTE_NAMES[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1);
157
157
  }
158
158
 
159
+ /** 聊天区的滚动容器(从 `[data-chat-flow]` 往上找第一个可纵向滚动的祖先)。 */
160
+ function findChatScroller() {
161
+ const flow = document.querySelector("[data-chat-flow]");
162
+ if (!flow) return null;
163
+ let sc = flow.parentElement;
164
+ while (sc && sc !== document.body) {
165
+ const cs = getComputedStyle(sc);
166
+ if (/(auto|scroll)/.test(cs.overflowY) && sc.clientHeight > 120) break;
167
+ sc = sc.parentElement;
168
+ }
169
+ return sc && sc !== document.body ? sc : flow;
170
+ }
171
+
159
172
  /**
160
- * 取当前视口里、纵坐标 y 处的那条消息(给右侧竖条滑动时做预览)。
173
+ * 聊天区滚动容器的视口矩形——把触发区钉在**聊天区右边缘**,
174
+ * 而不是窗口右边缘:右侧栏(文件预览等)打开时,窗口右边是面板,
175
+ * 不该在面板上触发、更不该让气泡盖住面板。
176
+ */
177
+ function chatViewport() {
178
+ const el = findChatScroller();
179
+ if (!el) return null;
180
+ const r = el.getBoundingClientRect();
181
+ return { right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom) };
182
+ }
183
+
184
+ /**
185
+ * 取**纵坐标 y 落在其范围内**的那条**用户消息**——只预览「我输入的」,
186
+ * 不预览 AI 的。稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
187
+ * `13:input-message<uuid>`(用户)/ `14:assistant-stepN:M`(AI)/
188
+ * `12:turn-processN`(过程块),类名带 hash 所以只认 data 属性。
161
189
  *
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 类名**。
190
+ * 刻意**不做「取最近一条」兜底**:滑到 AI / 过程块上就返回 null(预览收起),
191
+ * 否则同一条用户消息会在整片区域里被反复显示(就是「重复显示」)。
166
192
  */
167
- function messageAtY(y) {
193
+ function userMessageAtY(y) {
168
194
  const flow = document.querySelector("[data-chat-flow]");
169
195
  if (!flow) return null;
170
- let best = null;
171
- let bestDist = Infinity;
172
196
  for (const el of flow.children) {
173
197
  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 之类不是消息,跳过
198
+ if (!/input-message/.test(key)) continue;
178
199
  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 是消息时间戳,去掉
200
+ if (r.height <= 0 || y < r.top || y > r.bottom) continue;
201
+ // innerText 抓渲染后的正文;末尾的 HH:MM 是消息时间戳,去掉
185
202
  const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
186
203
  if (!text) continue;
187
- bestDist = dist;
188
- best = { role, text };
204
+ return { text, el };
189
205
  }
190
- return best;
206
+ return null;
191
207
  }
192
208
 
193
209
  //#region settings
@@ -218,6 +234,8 @@ window.__ModuleLoader__.load({
218
234
  const roleRef = useRef(null);
219
235
  const noteRef = useRef(null);
220
236
  const textRef = useRef(null);
237
+ const bubbleHoverRef = useRef(false);
238
+ const activeMsgRef = useRef(null);
221
239
 
222
240
  const setOn = useCallback((on) => {
223
241
  setSettings((s) => {
@@ -236,6 +254,36 @@ window.__ModuleLoader__.load({
236
254
  }, []);
237
255
 
238
256
  useEffect(() => {
257
+ let ro = null;
258
+ let roEl = null;
259
+ let mo = null;
260
+ let moTimer = 0;
261
+ /** 把竖条 / 圆点 / 气泡钉到聊天区右边缘(右侧面板打开时跟着左移)。 */
262
+ const placeAtChatEdge = () => {
263
+ const view = chatViewport();
264
+ const rightPx = view ? Math.max(0, window.innerWidth - view.right) : 0;
265
+ if (stripRef.current) stripRef.current.style.right = rightPx + "px";
266
+ if (markerRef.current) markerRef.current.style.right = rightPx + "px";
267
+ if (prevRef.current) prevRef.current.style.right = rightPx + 18 + "px";
268
+ // 面板开合 / 窗口缩放会改聊天容器宽度 → 挂 ResizeObserver 跟着走
269
+ const sc = findChatScroller();
270
+ if (ro && sc && sc !== roEl) {
271
+ if (roEl) ro.unobserve(roEl);
272
+ ro.observe(sc);
273
+ roEl = sc;
274
+ }
275
+ return view;
276
+ };
277
+ if (typeof ResizeObserver !== "undefined") ro = new ResizeObserver(() => placeAtChatEdge());
278
+ // 右侧面板开合是 DOM 增删(不一定触发 resize)→ 用 MutationObserver 兜底,
279
+ // 只监听 childList(不监听 attributes,避免自己写 style 造成回环)。
280
+ if (typeof MutationObserver !== "undefined") {
281
+ mo = new MutationObserver(() => {
282
+ if (moTimer) return;
283
+ moTimer = window.setTimeout(() => { moTimer = 0; placeAtChatEdge(); }, 200);
284
+ });
285
+ mo.observe(document.body, { childList: true, subtree: true });
286
+ }
239
287
  const showMarker = (y) => {
240
288
  const m = markerRef.current;
241
289
  if (m) m.style.top = Math.round(y - 3.5) + "px";
@@ -243,39 +291,45 @@ window.__ModuleLoader__.load({
243
291
  const showPreview = (y, noteText, msg) => {
244
292
  const el = prevRef.current;
245
293
  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
- }
254
- }
294
+ activeMsgRef.current = msg || null;
295
+ if (noteRef.current) noteRef.current.textContent = "♪ " + noteText + (msg ? " · 点击跳转" : "");
296
+ if (roleRef.current) roleRef.current.style.display = msg ? "" : "none";
255
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";
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";
260
305
  };
306
+ const hidePreview = () => {
307
+ activeMsgRef.current = null;
308
+ if (prevRef.current) {
309
+ prevRef.current.classList.remove("dshscp_prevOn");
310
+ prevRef.current.style.pointerEvents = "none";
311
+ }
312
+ if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
313
+ };
261
314
 
262
315
  const onMove = (e) => {
263
316
  const s = settingsRef.current;
264
317
  if (!s.on) return;
265
- const W = window.innerWidth;
266
- const H = window.innerHeight;
267
- if (e.clientX < W - s.stripWidth) {
318
+ if (bubbleHoverRef.current) return; // 鼠标停在气泡上(正要点击),别把它收掉
319
+ const view = placeAtChatEdge();
320
+ if (!view) return;
321
+ // 触发区 = 聊天区右边缘那一条;落在右侧面板上一律不响应
322
+ if (e.clientX < view.right - s.stripWidth || e.clientX > view.right + 2) {
268
323
  lastMidiRef.current = null;
269
- if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
270
- if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
324
+ hidePreview();
271
325
  return;
272
326
  }
273
- const span = Math.max(1, H - s.inset * 2);
327
+ const span = Math.max(1, window.innerHeight - s.inset * 2);
274
328
  const t = Math.min(1, Math.max(0, (e.clientY - s.inset) / span));
275
329
  // t=0(顶部)→ highNote;t=1(底部)→ lowNote
276
330
  const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
277
331
  showMarker(e.clientY);
278
- showPreview(e.clientY, noteLabel(midi) + " · " + midi, messageAtY(e.clientY));
332
+ showPreview(e.clientY, noteLabel(midi) + " · " + midi, userMessageAtY(e.clientY));
279
333
  if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
280
334
  if (prevRef.current) prevRef.current.classList.add("dshscp_prevOn");
281
335
  if (midi !== lastMidiRef.current) {
@@ -291,26 +345,51 @@ window.__ModuleLoader__.load({
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" }, "—"),
392
+ h("span", { ref: roleRef, className: "dshscp_prevRole" }, "你"),
314
393
  h("span", { ref: noteRef, className: "dshscp_prevNote" }, "♪")
315
394
  ),
316
395
  h("div", { ref: textRef, className: "dshscp_prevText" })
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.0",
4
4
  "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
5
  "type": "module",
6
6
  "license": "MIT",