dsh-scroll-piano 0.3.0 → 0.3.2
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 +3 -2
- package/lib/client.js +90 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
- 聊天区右边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
|
|
8
8
|
- 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
|
|
9
|
-
-
|
|
9
|
+
- 竖条上有**「我的消息」刻度**:你每发一条消息,对应高度就有一根蓝色小横杠——一眼看出自己发的话都在哪。
|
|
10
|
+
- **气泡只在鼠标贴住「你发的某条消息」时出现**(±24px 容差),内容就是那条消息的正文(最多 3 行)+「点击跳转」提示;**其余位置整个不显示**(不显示音名、不显示 MIDI 数字)。
|
|
10
11
|
- **点击气泡** → 平滑滚动,把那条消息滚到视口中央。
|
|
11
12
|
- 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
|
|
12
13
|
- 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
|
|
13
14
|
|
|
14
|
-
>
|
|
15
|
+
> 预览**只显示用户自己发的消息**,且**不做「取全局最近一条」兜底**:鼠标离你所有消息都超过 ±24px 时气泡整个收起——否则在一个只有一条用户消息的视口里,滑到哪都显示同一条(就是「重复显示」)。位置感交给竖条刻度。声音与预览**彼此独立**——任何位置滑动都照常出声,只有预览挑消息。
|
|
15
16
|
|
|
16
17
|
## 消息预览怎么定位
|
|
17
18
|
|
package/lib/client.js
CHANGED
|
@@ -30,6 +30,8 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
".dshscp_vol{width:76px;accent-color:var(--dsw-alias-state-business-primary,#5b8def)}",
|
|
31
31
|
".dshscp_strip{position:fixed;top:0;right:0;width:3px;height:100%;z-index:2147482000;pointer-events:none;background:linear-gradient(180deg,rgba(91,141,239,.85),rgba(160,120,240,.85));opacity:0;transition:opacity .15s ease}",
|
|
32
32
|
".dshscp_stripOn{opacity:.55}",
|
|
33
|
+
".dshscp_ticks{position:fixed;top:0;right:0;width:9px;height:100%;z-index:2147482000;pointer-events:none}",
|
|
34
|
+
".dshscp_tick{position:absolute;right:0;width:7px;height:2px;border-radius:1px;background:rgba(91,141,239,.95);box-shadow:0 0 4px rgba(91,141,239,.7)}",
|
|
33
35
|
".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
36
|
".dshscp_markerOn{opacity:1}",
|
|
35
37
|
".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}",
|
|
@@ -37,7 +39,7 @@ window.__ModuleLoader__.load({
|
|
|
37
39
|
".dshscp_prevTop{display:flex;align-items:center;gap:8px;margin-bottom:4px}",
|
|
38
40
|
".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
41
|
".dshscp_prevRoleAI{background:rgba(160,120,240,.28);color:#d6c4ff}",
|
|
40
|
-
".
|
|
42
|
+
".dshscp_prevHint{margin-left:auto;flex:none;color:#9aa2b1;font-size:10px;line-height:14px}",
|
|
41
43
|
".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
44
|
].join("");
|
|
43
45
|
const tagId = "dsh-scroll-piano/scroll-piano.css";
|
|
@@ -150,12 +152,7 @@ window.__ModuleLoader__.load({
|
|
|
150
152
|
noise.start(now);
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
//#region
|
|
154
|
-
const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
155
|
-
function noteLabel(midi) {
|
|
156
|
-
return NOTE_NAMES[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1);
|
|
157
|
-
}
|
|
158
|
-
|
|
155
|
+
//#region chat anchors
|
|
159
156
|
/** 聊天区的滚动容器(从 `[data-chat-flow]` 往上找第一个可纵向滚动的祖先)。 */
|
|
160
157
|
function findChatScroller() {
|
|
161
158
|
const flow = document.querySelector("[data-chat-flow]");
|
|
@@ -182,28 +179,51 @@ window.__ModuleLoader__.load({
|
|
|
182
179
|
}
|
|
183
180
|
|
|
184
181
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
182
|
+
* 取鼠标纵坐标附近的那条**用户消息**——只认「我输入的」,AI 回复一律不参与。
|
|
183
|
+
* 稳定锚点是 `[data-chat-flow]` 的直接子元素,key 形如
|
|
187
184
|
* `13:input-message<uuid>`(用户)/ `14:assistant-stepN:M`(AI)/
|
|
188
185
|
* `12:turn-processN`(过程块),类名带 hash 所以只认 data 属性。
|
|
189
186
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
187
|
+
* **必须贴住消息本体**(超出 ±TOL 就算没有):不搞「取全局最近一条」,
|
|
188
|
+
* 否则在只有一个用户消息的视口里,鼠标滑到哪都显示同一条 = 重复显示。
|
|
189
|
+
* 位置感交给竖条上的刻度(`userMessageTicks`)。
|
|
192
190
|
*/
|
|
193
191
|
function userMessageAtY(y) {
|
|
192
|
+
const TOL = 24; // 允许鼠标略微超出消息边界,滑动更宽容
|
|
194
193
|
const flow = document.querySelector("[data-chat-flow]");
|
|
195
194
|
if (!flow) return null;
|
|
195
|
+
let best = null;
|
|
196
|
+
let bestDist = Infinity;
|
|
196
197
|
for (const el of flow.children) {
|
|
197
198
|
const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
|
|
198
199
|
if (!/input-message/.test(key)) continue;
|
|
199
200
|
const r = el.getBoundingClientRect();
|
|
200
|
-
if (r.height <= 0
|
|
201
|
+
if (r.height <= 0) continue;
|
|
202
|
+
const dist = y < r.top ? r.top - y : (y > r.bottom ? y - r.bottom : 0);
|
|
203
|
+
if (dist > TOL || dist >= bestDist) continue;
|
|
201
204
|
// innerText 抓渲染后的正文;末尾的 HH:MM 是消息时间戳,去掉
|
|
202
205
|
const text = (el.innerText || "").replace(/\s+/g, " ").trim().replace(/\s*\d{1,2}:\d{2}$/, "").trim();
|
|
203
206
|
if (!text) continue;
|
|
204
|
-
|
|
207
|
+
bestDist = dist;
|
|
208
|
+
best = { text, el, dist };
|
|
209
|
+
}
|
|
210
|
+
return best;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 列出当前所有用户消息在视口里的纵向中心(给竖条上的刻度用)。 */
|
|
214
|
+
function userMessageTicks() {
|
|
215
|
+
const flow = document.querySelector("[data-chat-flow]");
|
|
216
|
+
if (!flow) return [];
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const el of flow.children) {
|
|
219
|
+
const key = String(el.getAttribute("data-chat-flow-key") || el.getAttribute("data-chat-node-key") || "");
|
|
220
|
+
if (!/input-message/.test(key)) continue;
|
|
221
|
+
const r = el.getBoundingClientRect();
|
|
222
|
+
if (r.height <= 0) continue;
|
|
223
|
+
if (!(el.innerText || "").trim()) continue;
|
|
224
|
+
out.push(Math.round(r.top + r.height / 2));
|
|
205
225
|
}
|
|
206
|
-
return
|
|
226
|
+
return out;
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
//#region settings
|
|
@@ -223,7 +243,6 @@ window.__ModuleLoader__.load({
|
|
|
223
243
|
//#region component
|
|
224
244
|
function ScrollPiano() {
|
|
225
245
|
const [settings, setSettings] = useState(loadSettings);
|
|
226
|
-
const [label, setLabel] = useState("");
|
|
227
246
|
const settingsRef = useRef(settings);
|
|
228
247
|
settingsRef.current = settings;
|
|
229
248
|
const lastMidiRef = useRef(null);
|
|
@@ -232,9 +251,8 @@ window.__ModuleLoader__.load({
|
|
|
232
251
|
const markerRef = useRef(null);
|
|
233
252
|
const prevRef = useRef(null);
|
|
234
253
|
const roleRef = useRef(null);
|
|
235
|
-
const noteRef = useRef(null);
|
|
236
254
|
const textRef = useRef(null);
|
|
237
|
-
const
|
|
255
|
+
const ticksRef = useRef(null);
|
|
238
256
|
const activeMsgRef = useRef(null);
|
|
239
257
|
|
|
240
258
|
const setOn = useCallback((on) => {
|
|
@@ -258,6 +276,25 @@ window.__ModuleLoader__.load({
|
|
|
258
276
|
let roEl = null;
|
|
259
277
|
let mo = null;
|
|
260
278
|
let moTimer = 0;
|
|
279
|
+
let tickTimer = 0;
|
|
280
|
+
/** 竖条上给「我的消息」打刻度:一眼看出自己发的话都在哪。 */
|
|
281
|
+
const refreshTicks = () => {
|
|
282
|
+
const box = ticksRef.current;
|
|
283
|
+
if (!box) return;
|
|
284
|
+
const ys = userMessageTicks();
|
|
285
|
+
while (box.children.length > ys.length) box.removeChild(box.lastChild);
|
|
286
|
+
while (box.children.length < ys.length) {
|
|
287
|
+
const i = document.createElement("i");
|
|
288
|
+
i.className = "dshscp_tick";
|
|
289
|
+
box.appendChild(i);
|
|
290
|
+
}
|
|
291
|
+
for (let i = 0; i < ys.length; i++) box.children[i].style.top = Math.round(ys[i]) + "px";
|
|
292
|
+
};
|
|
293
|
+
// 滚动 / 流式更新会让刻度移位;节流到 150ms,避免 pointermove 上做布局读取
|
|
294
|
+
const scheduleTicks = () => {
|
|
295
|
+
if (tickTimer) return;
|
|
296
|
+
tickTimer = window.setTimeout(() => { tickTimer = 0; refreshTicks(); }, 150);
|
|
297
|
+
};
|
|
261
298
|
/** 把竖条 / 圆点 / 气泡钉到聊天区右边缘(右侧面板打开时跟着左移)。 */
|
|
262
299
|
const placeAtChatEdge = () => {
|
|
263
300
|
const view = chatViewport();
|
|
@@ -265,6 +302,8 @@ window.__ModuleLoader__.load({
|
|
|
265
302
|
if (stripRef.current) stripRef.current.style.right = rightPx + "px";
|
|
266
303
|
if (markerRef.current) markerRef.current.style.right = rightPx + "px";
|
|
267
304
|
if (prevRef.current) prevRef.current.style.right = rightPx + 18 + "px";
|
|
305
|
+
if (ticksRef.current) ticksRef.current.style.right = rightPx + "px";
|
|
306
|
+
scheduleTicks();
|
|
268
307
|
// 面板开合 / 窗口缩放会改聊天容器宽度 → 挂 ResizeObserver 跟着走
|
|
269
308
|
const sc = findChatScroller();
|
|
270
309
|
if (ro && sc && sc !== roEl) {
|
|
@@ -288,20 +327,28 @@ window.__ModuleLoader__.load({
|
|
|
288
327
|
const m = markerRef.current;
|
|
289
328
|
if (m) m.style.top = Math.round(y - 3.5) + "px";
|
|
290
329
|
};
|
|
291
|
-
|
|
330
|
+
/**
|
|
331
|
+
* 气泡**只在鼠标压在某条「用户消息」上时出现**,内容只有那条消息的正文。
|
|
332
|
+
* 其余位置(AI 回复 / 工具块 / 空白)整个收起——不放音名、不放 MIDI 数字。
|
|
333
|
+
*/
|
|
334
|
+
const showPreview = (y, msg) => {
|
|
292
335
|
const el = prevRef.current;
|
|
293
336
|
if (!el) return;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
337
|
+
if (!msg) {
|
|
338
|
+
activeMsgRef.current = null;
|
|
339
|
+
el.classList.remove("dshscp_prevOn");
|
|
340
|
+
el.style.pointerEvents = "none";
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
activeMsgRef.current = msg;
|
|
344
|
+
if (textRef.current) textRef.current.textContent = msg.text;
|
|
345
|
+
el.style.pointerEvents = "auto";
|
|
346
|
+
el.style.cursor = "pointer";
|
|
301
347
|
// 竖直居中于鼠标,并夹在视口内
|
|
302
348
|
const h = el.offsetHeight || 40;
|
|
303
349
|
const top = Math.max(8, Math.min(window.innerHeight - h - 8, y - h / 2));
|
|
304
350
|
el.style.top = Math.round(top) + "px";
|
|
351
|
+
el.classList.add("dshscp_prevOn");
|
|
305
352
|
};
|
|
306
353
|
const hidePreview = () => {
|
|
307
354
|
activeMsgRef.current = null;
|
|
@@ -312,10 +359,18 @@ window.__ModuleLoader__.load({
|
|
|
312
359
|
if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
|
|
313
360
|
};
|
|
314
361
|
|
|
362
|
+
/** 鼠标是否正压在气泡上(压在气泡上时别收掉,用户要点它跳转)。 */
|
|
363
|
+
const overBubble = (x, y) => {
|
|
364
|
+
const el = prevRef.current;
|
|
365
|
+
if (!el || !el.classList.contains("dshscp_prevOn")) return false;
|
|
366
|
+
const r = el.getBoundingClientRect();
|
|
367
|
+
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
368
|
+
};
|
|
369
|
+
|
|
315
370
|
const onMove = (e) => {
|
|
316
371
|
const s = settingsRef.current;
|
|
317
372
|
if (!s.on) return;
|
|
318
|
-
if (
|
|
373
|
+
if (overBubble(e.clientX, e.clientY)) return; // 鼠标停在气泡上(正要点击)
|
|
319
374
|
const view = placeAtChatEdge();
|
|
320
375
|
if (!view) return;
|
|
321
376
|
// 触发区 = 聊天区右边缘那一条;落在右侧面板上一律不响应
|
|
@@ -329,9 +384,9 @@ window.__ModuleLoader__.load({
|
|
|
329
384
|
// t=0(顶部)→ highNote;t=1(底部)→ lowNote
|
|
330
385
|
const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
|
|
331
386
|
showMarker(e.clientY);
|
|
332
|
-
|
|
387
|
+
// 预览只挑「我发的消息」里离鼠标最近的那条(不显示音名/MIDI)
|
|
388
|
+
showPreview(e.clientY, userMessageAtY(e.clientY));
|
|
333
389
|
if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
|
|
334
|
-
if (prevRef.current) prevRef.current.classList.add("dshscp_prevOn");
|
|
335
390
|
if (midi !== lastMidiRef.current) {
|
|
336
391
|
lastMidiRef.current = midi;
|
|
337
392
|
// 限速:滑太快时不要瞬间堆几十个音(会糊成一片杂音)
|
|
@@ -340,46 +395,44 @@ window.__ModuleLoader__.load({
|
|
|
340
395
|
lastPlayRef.current = nowMs;
|
|
341
396
|
playNote(midi, s.volume);
|
|
342
397
|
}
|
|
343
|
-
setLabel(noteLabel(midi));
|
|
344
398
|
}
|
|
345
399
|
};
|
|
346
400
|
const onLeave = () => {
|
|
347
401
|
lastMidiRef.current = null;
|
|
348
|
-
bubbleHoverRef.current = false;
|
|
349
402
|
hidePreview();
|
|
350
403
|
};
|
|
351
404
|
document.addEventListener("pointermove", onMove, { passive: true });
|
|
352
405
|
document.addEventListener("pointerleave", onLeave);
|
|
406
|
+
document.addEventListener("scroll", scheduleTicks, { passive: true, capture: true });
|
|
353
407
|
window.addEventListener("blur", onLeave);
|
|
354
408
|
window.addEventListener("resize", placeAtChatEdge);
|
|
355
409
|
placeAtChatEdge();
|
|
356
410
|
return () => {
|
|
357
411
|
document.removeEventListener("pointermove", onMove);
|
|
358
412
|
document.removeEventListener("pointerleave", onLeave);
|
|
413
|
+
document.removeEventListener("scroll", scheduleTicks, { capture: true });
|
|
359
414
|
window.removeEventListener("blur", onLeave);
|
|
360
415
|
window.removeEventListener("resize", placeAtChatEdge);
|
|
361
416
|
if (ro) ro.disconnect();
|
|
362
417
|
if (mo) mo.disconnect();
|
|
363
418
|
if (moTimer) window.clearTimeout(moTimer);
|
|
419
|
+
if (tickTimer) window.clearTimeout(tickTimer);
|
|
364
420
|
};
|
|
365
421
|
}, []);
|
|
366
422
|
|
|
367
|
-
// 竖条 + 位置圆点 +
|
|
423
|
+
// 竖条 + 「我的消息」刻度 + 位置圆点 + 预览气泡(气泡可点,点了跳过去)
|
|
368
424
|
return h(React.Fragment, null,
|
|
369
425
|
h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
|
|
426
|
+
h("div", { ref: ticksRef, className: "dshscp_ticks" }),
|
|
370
427
|
h("div", { ref: markerRef, className: "dshscp_marker" }),
|
|
371
428
|
h("div", {
|
|
372
429
|
ref: prevRef,
|
|
373
430
|
className: "dshscp_prev",
|
|
374
|
-
// 鼠标移进气泡时别被 pointermove 收掉(要能点它跳转)
|
|
375
|
-
onMouseEnter: () => { bubbleHoverRef.current = true; },
|
|
376
|
-
onMouseLeave: () => { bubbleHoverRef.current = false; },
|
|
377
431
|
onClick: () => {
|
|
378
432
|
const m = activeMsgRef.current;
|
|
379
433
|
if (!m || !m.el) return;
|
|
380
434
|
try { m.el.scrollIntoView({ behavior: "smooth", block: "center" }); }
|
|
381
435
|
catch { try { m.el.scrollIntoView(); } catch {} }
|
|
382
|
-
bubbleHoverRef.current = false;
|
|
383
436
|
activeMsgRef.current = null;
|
|
384
437
|
if (prevRef.current) {
|
|
385
438
|
prevRef.current.classList.remove("dshscp_prevOn");
|
|
@@ -390,7 +443,7 @@ window.__ModuleLoader__.load({
|
|
|
390
443
|
},
|
|
391
444
|
h("div", { className: "dshscp_prevTop" },
|
|
392
445
|
h("span", { ref: roleRef, className: "dshscp_prevRole" }, "你"),
|
|
393
|
-
h("span", {
|
|
446
|
+
h("span", { className: "dshscp_prevHint" }, "点击跳转")
|
|
394
447
|
),
|
|
395
448
|
h("div", { ref: textRef, className: "dshscp_prevText" })
|
|
396
449
|
),
|
|
@@ -411,8 +464,7 @@ window.__ModuleLoader__.load({
|
|
|
411
464
|
value: settings.volume,
|
|
412
465
|
onChange: (e) => setVolume(parseFloat(e.target.value)),
|
|
413
466
|
title: "音量"
|
|
414
|
-
})
|
|
415
|
-
label ? h("span", { style: { opacity: 0.75, minWidth: "26px" } }, label) : null
|
|
467
|
+
})
|
|
416
468
|
)
|
|
417
469
|
);
|
|
418
470
|
}
|