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.
- package/README.md +14 -9
- package/lib/client.js +134 -56
- 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
|
-
-
|
|
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 消息 →
|
|
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
|
-
|
|
28
|
+
## 触发区:聊天区右边缘,不是窗口右边缘
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
滚动条本身是浏览器**原生滚动条**(不在 DOM 里、类名带 hash),所以触发用全局 `pointermove` + `clientX` 判定。但**判定基准是聊天区滚动容器的右边缘**(从 `[data-chat-flow]` 往上找第一个 `overflow-y:auto` 的祖先),不是窗口右边缘——否则右侧栏(文件预览等)打开时会在面板上触发、气泡还会盖住面板。
|
|
26
31
|
|
|
27
|
-
|
|
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
|
-
".
|
|
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
|
|
154
|
-
|
|
155
|
-
function
|
|
156
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* 还有 process / tool-call / turn-tail 这类非消息项。类名都带 hash(如
|
|
165
|
-
* `icaHSq_flowItem`),所以这里**只认 data 属性,不碰 hash 类名**。
|
|
185
|
+
* 刻意**不做「取最近一条」兜底**:滑到 AI / 过程块上就返回 null(预览收起),
|
|
186
|
+
* 否则同一条用户消息会在整片区域里被反复显示(就是「重复显示」)。
|
|
166
187
|
*/
|
|
167
|
-
function
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
188
|
-
best = { role, text };
|
|
199
|
+
return { text, el };
|
|
189
200
|
}
|
|
190
|
-
return
|
|
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
|
-
|
|
284
|
+
/**
|
|
285
|
+
* 气泡**只在鼠标压在某条「用户消息」上时出现**,内容只有那条消息的正文。
|
|
286
|
+
* 其余位置(AI 回复 / 工具块 / 空白)整个收起——不放音名、不放 MIDI 数字。
|
|
287
|
+
*/
|
|
288
|
+
const showPreview = (y, msg) => {
|
|
244
289
|
const el = prevRef.current;
|
|
245
290
|
if (!el) return;
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
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
|
-
|
|
270
|
-
if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
|
|
325
|
+
hidePreview();
|
|
271
326
|
return;
|
|
272
327
|
}
|
|
273
|
-
const span = Math.max(1,
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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", {
|
|
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", {
|
|
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
|
}
|