dsh-scroll-piano 0.1.0 → 0.2.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 +15 -3
  2. package/lib/client.js +73 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,13 +6,25 @@
6
6
 
7
7
  - 右侧边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
8
8
  - 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
9
- - 鼠标旁显示当前音名(如 `C4 · 60`)。
9
+ - 鼠标旁浮出**该位置的消息预览气泡**(类似滚动条的预览 tooltip):角色标签(`你` / `AI`)+ 当前音名(如 `♪ C4 · 60`)+ 该高度那条消息的正文(最多 3 行)。
10
10
  - 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
11
11
  - 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
12
12
 
13
- ## 为什么按坐标而不是抓 DOM
13
+ ## 消息预览怎么定位
14
14
 
15
- 那根滚动条是浏览器**原生滚动条**——不在 DOM 里,且周围容器类名带 hash(`ST7X_W_scrollBody` 这类),抓不到也没法定制。所以插件用全局 `pointermove` + `clientX` 判定「是否落在窗口右侧竖条区间」、`clientY` 映射音高,稳且不依赖任何内部类名。
15
+ 聊天流的稳定锚点是 **`[data-chat-flow]`**:它的**直接子元素**一条消息一个,key 形如:
16
+
17
+ | key 前缀 | 含义 |
18
+ |---|---|
19
+ | `13:input-message<uuid>` | 用户消息 → 角色显示「你」 |
20
+ | `14:assistant-stepN:M` | AI 消息 → 角色显示「AI」 |
21
+ | `12:turn-processN` / `["process",…]` / `9:turn-tailN` | 过程 / 工具调用 / 轮次尾,**不预览** |
22
+
23
+ 滑动时取鼠标纵坐标 y 落在哪条子元素的 `getBoundingClientRect()` 里,`innerText` 取正文(去掉末尾 `HH:MM` 时间戳),显示在气泡里。**只认这些 `data-*` 属性**——容器类名(`icaHSq_flowItem`、`ST7X_W_scrollBody`)都带 hash,不碰。
24
+
25
+ ## 触发区为什么按坐标而不是抓 DOM
26
+
27
+ 那根滚动条本身是浏览器**原生滚动条**——不在 DOM 里,周围容器类名还带 hash,抓不到也没法定制。所以插件用全局 `pointermove` + `clientX` 判定「是否落在窗口右侧竖条区间」、`clientY` 映射音高;只有**取消息预览**时才去读 `[data-chat-flow]` 的 `data-*` 稳定属性。
16
28
 
17
29
  ## 可调参数
18
30
 
package/lib/client.js CHANGED
@@ -32,8 +32,13 @@ window.__ModuleLoader__.load({
32
32
  ".dshscp_stripOn{opacity:.55}",
33
33
  ".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
34
  ".dshscp_markerOn{opacity:1}",
35
- ".dshscp_hud{position:fixed;z-index:2147482002;pointer-events:none;padding:2px 7px;border-radius:6px;background:rgba(20,24,32,.92);color:#e6e8ee;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums;opacity:0;transition:opacity .1s ease;white-space:nowrap}",
36
- ".dshscp_hudOn{opacity:1}"
35
+ ".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}",
36
+ ".dshscp_prevOn{opacity:1}",
37
+ ".dshscp_prevTop{display:flex;align-items:center;gap:8px;margin-bottom:4px}",
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
+ ".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}",
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}"
37
42
  ].join("");
38
43
  const tagId = "dsh-scroll-piano/scroll-piano.css";
39
44
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -151,6 +156,40 @@ window.__ModuleLoader__.load({
151
156
  return NOTE_NAMES[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1);
152
157
  }
153
158
 
159
+ /**
160
+ * 取当前视口里、纵坐标 y 处的那条消息(给右侧竖条滑动时做预览)。
161
+ *
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 类名**。
166
+ */
167
+ function messageAtY(y) {
168
+ const flow = document.querySelector("[data-chat-flow]");
169
+ if (!flow) return null;
170
+ let best = null;
171
+ let bestDist = Infinity;
172
+ for (const el of flow.children) {
173
+ 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 之类不是消息,跳过
178
+ 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 是消息时间戳,去掉
185
+ const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
186
+ if (!text) continue;
187
+ bestDist = dist;
188
+ best = { role, text };
189
+ }
190
+ return best;
191
+ }
192
+
154
193
  //#region settings
155
194
  const LS_KEY = "dsh-scroll-piano:settings";
156
195
  const DEFAULTS = { on: true, volume: 0.25, stripWidth: 30, lowNote: 48, highNote: 84, inset: 56 };
@@ -175,7 +214,10 @@ window.__ModuleLoader__.load({
175
214
  const lastPlayRef = useRef(0);
176
215
  const stripRef = useRef(null);
177
216
  const markerRef = useRef(null);
178
- const hudRef = useRef(null);
217
+ const prevRef = useRef(null);
218
+ const roleRef = useRef(null);
219
+ const noteRef = useRef(null);
220
+ const textRef = useRef(null);
179
221
 
180
222
  const setOn = useCallback((on) => {
181
223
  setSettings((s) => {
@@ -198,12 +240,23 @@ window.__ModuleLoader__.load({
198
240
  const m = markerRef.current;
199
241
  if (m) m.style.top = Math.round(y - 3.5) + "px";
200
242
  };
201
- const showHud = (y, text) => {
202
- const el = hudRef.current;
243
+ const showPreview = (y, noteText, msg) => {
244
+ const el = prevRef.current;
203
245
  if (!el) return;
204
- el.style.top = Math.round(y - 10) + "px";
205
- el.style.right = "16px";
206
- el.textContent = text;
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
+ }
255
+ if (textRef.current) textRef.current.textContent = msg ? msg.text : "";
256
+ // 竖直居中于鼠标,并夹在视口内
257
+ const h = el.offsetHeight || 56;
258
+ const top = Math.max(8, Math.min(window.innerHeight - h - 8, y - h / 2));
259
+ el.style.top = Math.round(top) + "px";
207
260
  };
208
261
 
209
262
  const onMove = (e) => {
@@ -214,7 +267,7 @@ window.__ModuleLoader__.load({
214
267
  if (e.clientX < W - s.stripWidth) {
215
268
  lastMidiRef.current = null;
216
269
  if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
217
- if (hudRef.current) hudRef.current.classList.remove("dshscp_hudOn");
270
+ if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
218
271
  return;
219
272
  }
220
273
  const span = Math.max(1, H - s.inset * 2);
@@ -222,9 +275,9 @@ window.__ModuleLoader__.load({
222
275
  // t=0(顶部)→ highNote;t=1(底部)→ lowNote
223
276
  const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
224
277
  showMarker(e.clientY);
225
- showHud(e.clientY, noteLabel(midi) + " · " + midi);
278
+ showPreview(e.clientY, noteLabel(midi) + " · " + midi, messageAtY(e.clientY));
226
279
  if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
227
- if (hudRef.current) hudRef.current.classList.add("dshscp_hudOn");
280
+ if (prevRef.current) prevRef.current.classList.add("dshscp_prevOn");
228
281
  if (midi !== lastMidiRef.current) {
229
282
  lastMidiRef.current = midi;
230
283
  // 限速:滑太快时不要瞬间堆几十个音(会糊成一片杂音)
@@ -239,7 +292,7 @@ window.__ModuleLoader__.load({
239
292
  const onLeave = () => {
240
293
  lastMidiRef.current = null;
241
294
  if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
242
- if (hudRef.current) hudRef.current.classList.remove("dshscp_hudOn");
295
+ if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
243
296
  };
244
297
  document.addEventListener("pointermove", onMove, { passive: true });
245
298
  document.addEventListener("pointerleave", onLeave);
@@ -251,11 +304,17 @@ window.__ModuleLoader__.load({
251
304
  };
252
305
  }, []);
253
306
 
254
- // 竖条指示 + 位置圆点 + 音符 HUD(纯视觉,不拦事件)
307
+ // 竖条指示 + 位置圆点 + 「该位置消息预览」气泡(纯视觉,不拦事件)
255
308
  return h(React.Fragment, null,
256
309
  h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
257
310
  h("div", { ref: markerRef, className: "dshscp_marker" }),
258
- h("div", { ref: hudRef, className: "dshscp_hud" }),
311
+ h("div", { ref: prevRef, className: "dshscp_prev" },
312
+ h("div", { className: "dshscp_prevTop" },
313
+ h("span", { ref: roleRef, className: "dshscp_prevRole" }, "—"),
314
+ h("span", { ref: noteRef, className: "dshscp_prevNote" }, "♪")
315
+ ),
316
+ h("div", { ref: textRef, className: "dshscp_prevText" })
317
+ ),
259
318
  h("div", { className: "dshscp_pill" },
260
319
  h("button", {
261
320
  onClick: () => setOn(!settings.on),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scroll-piano",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
5
5
  "type": "module",
6
6
  "license": "MIT",