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.
- package/README.md +15 -3
- package/lib/client.js +73 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,13 +6,25 @@
|
|
|
6
6
|
|
|
7
7
|
- 右侧边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
|
|
8
8
|
- 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
|
|
9
|
-
-
|
|
9
|
+
- 鼠标旁浮出**该位置的消息预览气泡**(类似滚动条的预览 tooltip):角色标签(`你` / `AI`)+ 当前音名(如 `♪ C4 · 60`)+ 该高度那条消息的正文(最多 3 行)。
|
|
10
10
|
- 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
|
|
11
11
|
- 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## 消息预览怎么定位
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
".
|
|
36
|
-
".
|
|
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
|
|
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
|
|
202
|
-
const el =
|
|
243
|
+
const showPreview = (y, noteText, msg) => {
|
|
244
|
+
const el = prevRef.current;
|
|
203
245
|
if (!el) return;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 (
|
|
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
|
-
|
|
278
|
+
showPreview(e.clientY, noteLabel(midi) + " · " + midi, messageAtY(e.clientY));
|
|
226
279
|
if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
|
|
227
|
-
if (
|
|
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 (
|
|
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
|
-
// 竖条指示 + 位置圆点 +
|
|
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:
|
|
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),
|