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.
- package/README.md +14 -9
- package/lib/client.js +122 -43
- 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
|
+
- 气泡恒显当前音名(如 `♪ 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 消息 →
|
|
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
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* 还有 process / tool-call / turn-tail 这类非消息项。类名都带 hash(如
|
|
165
|
-
* `icaHSq_flowItem`),所以这里**只认 data 属性,不碰 hash 类名**。
|
|
190
|
+
* 刻意**不做「取最近一条」兜底**:滑到 AI / 过程块上就返回 null(预览收起),
|
|
191
|
+
* 否则同一条用户消息会在整片区域里被反复显示(就是「重复显示」)。
|
|
166
192
|
*/
|
|
167
|
-
function
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
188
|
-
best = { role, text };
|
|
204
|
+
return { text, el };
|
|
189
205
|
}
|
|
190
|
-
return
|
|
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
|
-
|
|
247
|
-
if (
|
|
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 ||
|
|
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
|
-
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
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
|
-
|
|
270
|
-
if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
|
|
324
|
+
hidePreview();
|
|
271
325
|
return;
|
|
272
326
|
}
|
|
273
|
-
const span = Math.max(1,
|
|
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,
|
|
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
|
-
|
|
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" }, "
|
|
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" })
|