dsh-scroll-piano 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +74 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.js +356 -0
- package/lib/index.js +9 -0
- package/package.json +56 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-piano contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-scroll-piano · 滚动钢琴
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
鼠标滑过 DSH 界面**右侧边缘的竖条区域**(聊天区滚动条/把手那一条)时,按鼠标的**纵向位置**发出钢琴音:**越靠上音越高、越靠下音越低**(特雷门琴式)。
|
|
4
|
+
|
|
5
|
+
## 效果
|
|
6
|
+
|
|
7
|
+
- 右侧边缘一条细彩色竖条(开启时淡淡发光),鼠标进入该区间即触发。
|
|
8
|
+
- 鼠标纵向移动 = 连续刮奏;竖条上有个白点跟随鼠标位置。
|
|
9
|
+
- 鼠标旁浮出**该位置的消息预览气泡**(类似滚动条的预览 tooltip):角色标签(`你` / `AI`)+ 当前音名(如 `♪ C4 · 60`)+ 该高度那条消息的正文(最多 3 行)。
|
|
10
|
+
- 左下角一枚 `🎵 滚动钢琴 ON/OFF` 胶囊:点开关切换、右侧滑块调音量(设置存 `localStorage`)。
|
|
11
|
+
- 默认音域 C3–C6(3 个八度),只按半音触发(同音不重复触发)。
|
|
12
|
+
|
|
13
|
+
## 消息预览怎么定位
|
|
14
|
+
|
|
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-*` 稳定属性。
|
|
28
|
+
|
|
29
|
+
## 可调参数
|
|
30
|
+
|
|
31
|
+
代码内 `DEFAULTS`(`lib/client.js`):
|
|
32
|
+
|
|
33
|
+
| 键 | 默认 | 说明 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `on` | `true` | 开关 |
|
|
36
|
+
| `volume` | `0.25` | 音量 |
|
|
37
|
+
| `stripWidth` | `30` | 右侧竖条宽度(px) |
|
|
38
|
+
| `lowNote` / `highNote` | `48` / `84` | 音域(MIDI,C3–C6) |
|
|
39
|
+
| `inset` | `56` | 上下留白(px),让最高/最低音够得着 |
|
|
40
|
+
|
|
41
|
+
## 技术要点
|
|
42
|
+
|
|
43
|
+
| 项 | 做法 |
|
|
44
|
+
|---|---|
|
|
45
|
+
| 挂载槽位 | `shell.overlay` |
|
|
46
|
+
| 触发 | `document.addEventListener("pointermove")`,`clientX >= innerWidth - stripWidth` 才生效 |
|
|
47
|
+
| 音高 | `midi = highNote - t*(highNote-lowNote)`,`t` 为纵向归一化位置 |
|
|
48
|
+
| 音频 | Web Audio 合成(泛音 + 快速起音 + 指数衰减 + 噪声锤击瞬态 + 低通 + 压缩器) |
|
|
49
|
+
| 客户端形态 | 手写 `window.__ModuleLoader__.load` 零构建 bundle,`platform: web` |
|
|
50
|
+
|
|
51
|
+
## 安装(本地)
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
NODE=~/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/node/bin/node
|
|
55
|
+
PNPM=~/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/pnpm/bin/pnpm.mjs
|
|
56
|
+
# profile/package.json 的 dependencies 加: "dsh-scroll-piano": "link:<本目录>"
|
|
57
|
+
# profile/cordis.patch.yml 加:
|
|
58
|
+
# - insert:
|
|
59
|
+
# - id: dsh-scroll-piano
|
|
60
|
+
# name: dsh-scroll-piano
|
|
61
|
+
cd ~/.dsh/profiles/desktop && "$NODE" "$PNPM" install --no-frozen-lockfile
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> 顺序很重要:**先 pnpm install 建软链,再触发 cordis.patch.yml 热载入**,否则 loader 首次 require 找不到包、之后不会自动重试。
|
|
65
|
+
|
|
66
|
+
## 开发
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm test # node test/bundle-check.mjs
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 说明
|
|
73
|
+
|
|
74
|
+
- 首次出声需要一次用户手势(点开关或首次滑动即自动解锁 AudioContext)。
|
|
75
|
+
- 与 `dsh-piano`(滑动钢琴键盘)是两个独立插件,可同时装。
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-scroll-piano — browser half.
|
|
3
|
+
*
|
|
4
|
+
* 鼠标滑过 DSH 界面**右侧边缘的竖条区域**(聊天区滚动条/把手那一条)时,
|
|
5
|
+
* 按鼠标的**纵向位置**发出钢琴音:越靠上音越高、越靠下音越低(特雷门琴式)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么按「窗口右侧竖条 + 坐标」而不是抓某个 DOM 元素:那根滚动条是浏览器
|
|
8
|
+
* **原生滚动条**(不在 DOM 里,类名还带 hash),抓不到也没法定制;用一个全局
|
|
9
|
+
* `pointermove` + `clientX` 落到竖条区间、`clientY` 映射音高,最稳。
|
|
10
|
+
*
|
|
11
|
+
* 声音沿用 dsh-piano 的 Web Audio 实时合成(泛音 + 快速起音 + 指数衰减 +
|
|
12
|
+
* 噪声锤击瞬态 + 低通/压缩),零采样、零网络、零依赖。
|
|
13
|
+
*/
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: "dsh-scroll-piano",
|
|
16
|
+
factory: (require) => {
|
|
17
|
+
var module = { exports: {} };
|
|
18
|
+
var exports = module.exports;
|
|
19
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
20
|
+
const React = require("react");
|
|
21
|
+
const { useEffect, useRef, useState, useCallback } = React;
|
|
22
|
+
const h = React.createElement;
|
|
23
|
+
|
|
24
|
+
//#region styles
|
|
25
|
+
const CSS = [
|
|
26
|
+
".dshscp_pill{box-sizing:border-box;position:fixed;left:132px;bottom:16px;z-index:2147483000;height:32px;display:inline-flex;align-items:center;gap:10px;padding:0 10px;border-radius:16px;background:var(--dsw-alias-bg-elevated,#1f2430);color:var(--dsw-alias-label-primary,#e6e8ee);box-shadow:0 4px 18px rgba(0,0,0,.35);font-size:12px;line-height:16px}",
|
|
27
|
+
".dshscp_pill button{box-sizing:border-box;border:none;background:transparent;color:inherit;font:inherit;cursor:pointer;padding:0 2px;display:inline-flex;align-items:center;gap:6px}",
|
|
28
|
+
".dshscp_dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#6b7280)}",
|
|
29
|
+
".dshscp_dotOn{background:#35c46a;box-shadow:0 0 6px rgba(53,196,106,.9)}",
|
|
30
|
+
".dshscp_vol{width:76px;accent-color:var(--dsw-alias-state-business-primary,#5b8def)}",
|
|
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
|
+
".dshscp_stripOn{opacity:.55}",
|
|
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
|
+
".dshscp_markerOn{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}"
|
|
42
|
+
].join("");
|
|
43
|
+
const tagId = "dsh-scroll-piano/scroll-piano.css";
|
|
44
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
45
|
+
const style = document.createElement("style");
|
|
46
|
+
style.setAttribute("data-plugin-css", tagId);
|
|
47
|
+
style.textContent = CSS;
|
|
48
|
+
document.head.appendChild(style);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//#region audio engine
|
|
52
|
+
let _ctx = null;
|
|
53
|
+
let _out = null;
|
|
54
|
+
let _noiseBuf = null;
|
|
55
|
+
function audioCtx() {
|
|
56
|
+
if (!_ctx) {
|
|
57
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
58
|
+
if (!AC) return null;
|
|
59
|
+
_ctx = new AC();
|
|
60
|
+
}
|
|
61
|
+
if (_ctx.state === "suspended") _ctx.resume().catch(() => {});
|
|
62
|
+
return _ctx;
|
|
63
|
+
}
|
|
64
|
+
function midiToFreq(m) {
|
|
65
|
+
return 440 * Math.pow(2, (m - 69) / 12);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 共享输出链(只建一次):总线 → 低通 → 压缩器。
|
|
69
|
+
* 所有音共用一个压缩器,滑奏时多音叠加由它统一压住:既防削波,
|
|
70
|
+
* 也避免每个音都重建一条 master/lowpass/compressor 链。
|
|
71
|
+
*/
|
|
72
|
+
function outputChain(ctx) {
|
|
73
|
+
if (_out) return _out;
|
|
74
|
+
const master = ctx.createGain();
|
|
75
|
+
master.gain.value = 0.85;
|
|
76
|
+
const lp = ctx.createBiquadFilter();
|
|
77
|
+
lp.type = "lowpass";
|
|
78
|
+
lp.frequency.value = 6500;
|
|
79
|
+
lp.Q.value = 0.3;
|
|
80
|
+
const comp = ctx.createDynamicsCompressor();
|
|
81
|
+
comp.threshold.value = -22;
|
|
82
|
+
comp.knee.value = 24;
|
|
83
|
+
comp.ratio.value = 12;
|
|
84
|
+
comp.attack.value = 0.004;
|
|
85
|
+
comp.release.value = 0.18;
|
|
86
|
+
master.connect(lp);
|
|
87
|
+
lp.connect(comp);
|
|
88
|
+
comp.connect(ctx.destination);
|
|
89
|
+
_out = master;
|
|
90
|
+
return master;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 缓存的锤击噪声缓冲(只建一次)。
|
|
94
|
+
* 上一版每个音都新建一个 AudioBuffer,快速滑动时分配抖动会引入杂音。
|
|
95
|
+
*/
|
|
96
|
+
function noiseBuffer(ctx) {
|
|
97
|
+
if (_noiseBuf) return _noiseBuf;
|
|
98
|
+
const len = Math.max(1, Math.floor(ctx.sampleRate * 0.02));
|
|
99
|
+
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
|
100
|
+
const data = buf.getChannelData(0);
|
|
101
|
+
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 3);
|
|
102
|
+
_noiseBuf = buf;
|
|
103
|
+
return buf;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 合成一枚「电钢」音:基音 + 3 枚泛音(各带指数衰减)+ 极轻的击弦瞬态。
|
|
107
|
+
* 泛音改成更少更柔的正弦、瞬态降到 0.05 倍,叠加时靠共享压缩器兜底。
|
|
108
|
+
*/
|
|
109
|
+
function playNote(midi, volume) {
|
|
110
|
+
if (volume <= 0.01) return;
|
|
111
|
+
const ctx = audioCtx();
|
|
112
|
+
if (!ctx) return;
|
|
113
|
+
const out = outputChain(ctx);
|
|
114
|
+
const freq = midiToFreq(midi);
|
|
115
|
+
const now = ctx.currentTime;
|
|
116
|
+
const v = Math.max(0.02, Math.min(1, volume));
|
|
117
|
+
|
|
118
|
+
const partials = [
|
|
119
|
+
{ r: 1, a: 0.85, d: 2.0, type: "sine" },
|
|
120
|
+
{ r: 2, a: 0.28, d: 1.1, type: "sine" },
|
|
121
|
+
{ r: 3, a: 0.11, d: 0.6, type: "sine" },
|
|
122
|
+
{ r: 4, a: 0.045, d: 0.4, type: "sine" }
|
|
123
|
+
];
|
|
124
|
+
for (const p of partials) {
|
|
125
|
+
const osc = ctx.createOscillator();
|
|
126
|
+
osc.type = p.type;
|
|
127
|
+
osc.frequency.value = freq * p.r;
|
|
128
|
+
osc.detune.value = (Math.random() - 0.5) * 3;
|
|
129
|
+
const g = ctx.createGain();
|
|
130
|
+
g.gain.setValueAtTime(0.0001, now);
|
|
131
|
+
g.gain.linearRampToValueAtTime(p.a * v, now + 0.006);
|
|
132
|
+
g.gain.exponentialRampToValueAtTime(0.0001, now + p.d);
|
|
133
|
+
osc.connect(g);
|
|
134
|
+
g.connect(out);
|
|
135
|
+
osc.start(now);
|
|
136
|
+
osc.stop(now + p.d + 0.08);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 极轻的击弦瞬态:只留一点触键质感,不再是明显嘶声
|
|
140
|
+
const noise = ctx.createBufferSource();
|
|
141
|
+
noise.buffer = noiseBuffer(ctx);
|
|
142
|
+
const hp = ctx.createBiquadFilter();
|
|
143
|
+
hp.type = "highpass";
|
|
144
|
+
hp.frequency.value = 2500;
|
|
145
|
+
const ng = ctx.createGain();
|
|
146
|
+
ng.gain.value = v * 0.05;
|
|
147
|
+
noise.connect(hp);
|
|
148
|
+
hp.connect(ng);
|
|
149
|
+
ng.connect(out);
|
|
150
|
+
noise.start(now);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
//#region note naming
|
|
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
|
+
|
|
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
|
+
|
|
193
|
+
//#region settings
|
|
194
|
+
const LS_KEY = "dsh-scroll-piano:settings";
|
|
195
|
+
const DEFAULTS = { on: true, volume: 0.25, stripWidth: 30, lowNote: 48, highNote: 84, inset: 56 };
|
|
196
|
+
function loadSettings() {
|
|
197
|
+
try {
|
|
198
|
+
return { ...DEFAULTS, ...(JSON.parse(localStorage.getItem(LS_KEY) || "{}")) };
|
|
199
|
+
} catch {
|
|
200
|
+
return { ...DEFAULTS };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function saveSettings(s) {
|
|
204
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
//#region component
|
|
208
|
+
function ScrollPiano() {
|
|
209
|
+
const [settings, setSettings] = useState(loadSettings);
|
|
210
|
+
const [label, setLabel] = useState("");
|
|
211
|
+
const settingsRef = useRef(settings);
|
|
212
|
+
settingsRef.current = settings;
|
|
213
|
+
const lastMidiRef = useRef(null);
|
|
214
|
+
const lastPlayRef = useRef(0);
|
|
215
|
+
const stripRef = useRef(null);
|
|
216
|
+
const markerRef = useRef(null);
|
|
217
|
+
const prevRef = useRef(null);
|
|
218
|
+
const roleRef = useRef(null);
|
|
219
|
+
const noteRef = useRef(null);
|
|
220
|
+
const textRef = useRef(null);
|
|
221
|
+
|
|
222
|
+
const setOn = useCallback((on) => {
|
|
223
|
+
setSettings((s) => {
|
|
224
|
+
const next = { ...s, on };
|
|
225
|
+
saveSettings(next);
|
|
226
|
+
return next;
|
|
227
|
+
});
|
|
228
|
+
if (on) audioCtx();
|
|
229
|
+
}, []);
|
|
230
|
+
const setVolume = useCallback((volume) => {
|
|
231
|
+
setSettings((s) => {
|
|
232
|
+
const next = { ...s, volume };
|
|
233
|
+
saveSettings(next);
|
|
234
|
+
return next;
|
|
235
|
+
});
|
|
236
|
+
}, []);
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
const showMarker = (y) => {
|
|
240
|
+
const m = markerRef.current;
|
|
241
|
+
if (m) m.style.top = Math.round(y - 3.5) + "px";
|
|
242
|
+
};
|
|
243
|
+
const showPreview = (y, noteText, msg) => {
|
|
244
|
+
const el = prevRef.current;
|
|
245
|
+
if (!el) return;
|
|
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";
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const onMove = (e) => {
|
|
263
|
+
const s = settingsRef.current;
|
|
264
|
+
if (!s.on) return;
|
|
265
|
+
const W = window.innerWidth;
|
|
266
|
+
const H = window.innerHeight;
|
|
267
|
+
if (e.clientX < W - s.stripWidth) {
|
|
268
|
+
lastMidiRef.current = null;
|
|
269
|
+
if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
|
|
270
|
+
if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const span = Math.max(1, H - s.inset * 2);
|
|
274
|
+
const t = Math.min(1, Math.max(0, (e.clientY - s.inset) / span));
|
|
275
|
+
// t=0(顶部)→ highNote;t=1(底部)→ lowNote
|
|
276
|
+
const midi = Math.round(s.highNote - t * (s.highNote - s.lowNote));
|
|
277
|
+
showMarker(e.clientY);
|
|
278
|
+
showPreview(e.clientY, noteLabel(midi) + " · " + midi, messageAtY(e.clientY));
|
|
279
|
+
if (markerRef.current) markerRef.current.classList.add("dshscp_markerOn");
|
|
280
|
+
if (prevRef.current) prevRef.current.classList.add("dshscp_prevOn");
|
|
281
|
+
if (midi !== lastMidiRef.current) {
|
|
282
|
+
lastMidiRef.current = midi;
|
|
283
|
+
// 限速:滑太快时不要瞬间堆几十个音(会糊成一片杂音)
|
|
284
|
+
const nowMs = performance.now();
|
|
285
|
+
if (nowMs - lastPlayRef.current >= 24) {
|
|
286
|
+
lastPlayRef.current = nowMs;
|
|
287
|
+
playNote(midi, s.volume);
|
|
288
|
+
}
|
|
289
|
+
setLabel(noteLabel(midi));
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const onLeave = () => {
|
|
293
|
+
lastMidiRef.current = null;
|
|
294
|
+
if (markerRef.current) markerRef.current.classList.remove("dshscp_markerOn");
|
|
295
|
+
if (prevRef.current) prevRef.current.classList.remove("dshscp_prevOn");
|
|
296
|
+
};
|
|
297
|
+
document.addEventListener("pointermove", onMove, { passive: true });
|
|
298
|
+
document.addEventListener("pointerleave", onLeave);
|
|
299
|
+
window.addEventListener("blur", onLeave);
|
|
300
|
+
return () => {
|
|
301
|
+
document.removeEventListener("pointermove", onMove);
|
|
302
|
+
document.removeEventListener("pointerleave", onLeave);
|
|
303
|
+
window.removeEventListener("blur", onLeave);
|
|
304
|
+
};
|
|
305
|
+
}, []);
|
|
306
|
+
|
|
307
|
+
// 竖条指示 + 位置圆点 + 「该位置消息预览」气泡(纯视觉,不拦事件)
|
|
308
|
+
return h(React.Fragment, null,
|
|
309
|
+
h("div", { ref: stripRef, className: "dshscp_strip" + (settings.on ? " dshscp_stripOn" : "") }),
|
|
310
|
+
h("div", { ref: markerRef, className: "dshscp_marker" }),
|
|
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
|
+
),
|
|
318
|
+
h("div", { className: "dshscp_pill" },
|
|
319
|
+
h("button", {
|
|
320
|
+
onClick: () => setOn(!settings.on),
|
|
321
|
+
title: settings.on ? "关闭滚动钢琴" : "开启滚动钢琴"
|
|
322
|
+
},
|
|
323
|
+
h("span", { className: "dshscp_dot" + (settings.on ? " dshscp_dotOn" : "") }),
|
|
324
|
+
"🎵 滚动钢琴" + (settings.on ? " ON" : " OFF")
|
|
325
|
+
),
|
|
326
|
+
h("input", {
|
|
327
|
+
className: "dshscp_vol",
|
|
328
|
+
type: "range",
|
|
329
|
+
min: 0,
|
|
330
|
+
max: 1,
|
|
331
|
+
step: 0.05,
|
|
332
|
+
value: settings.volume,
|
|
333
|
+
onChange: (e) => setVolume(parseFloat(e.target.value)),
|
|
334
|
+
title: "音量"
|
|
335
|
+
}),
|
|
336
|
+
label ? h("span", { style: { opacity: 0.75, minWidth: "26px" } }, label) : null
|
|
337
|
+
)
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
//#region apply
|
|
342
|
+
const inject = ["slots"];
|
|
343
|
+
function apply(ctx) {
|
|
344
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
345
|
+
name: "shell.overlay",
|
|
346
|
+
id: "dsh-scroll-piano:overlay",
|
|
347
|
+
order: 110,
|
|
348
|
+
inject: () => ({})
|
|
349
|
+
}, ScrollPiano));
|
|
350
|
+
}
|
|
351
|
+
exports.apply = apply;
|
|
352
|
+
exports.inject = inject;
|
|
353
|
+
|
|
354
|
+
return module.exports;
|
|
355
|
+
}
|
|
356
|
+
});
|
package/lib/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-scroll-piano",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "滚动钢琴:鼠标滑过 DSH 右侧边缘的滚动条竖条时,按纵向位置发出钢琴音(越高音越高,特雷门琴式)。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"piano",
|
|
12
|
+
"scrollbar",
|
|
13
|
+
"theremin",
|
|
14
|
+
"钢琴",
|
|
15
|
+
"滚动钢琴"
|
|
16
|
+
],
|
|
17
|
+
"main": "lib/index.js",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"default": "./lib/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./client": {
|
|
23
|
+
"default": "./lib/client.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/**/*.js",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22",
|
|
35
|
+
"dsh": ">=0.2.0-rc.1"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
39
|
+
},
|
|
40
|
+
"dsh": {
|
|
41
|
+
"bundle": {
|
|
42
|
+
"patch": "./cordis.patch.yml"
|
|
43
|
+
},
|
|
44
|
+
"client": {
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
47
|
+
],
|
|
48
|
+
"platform": "web"
|
|
49
|
+
},
|
|
50
|
+
"compatibility": {
|
|
51
|
+
"dshReleases": {
|
|
52
|
+
"0.2.0-rc.2": "compatible"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "node test/bundle-check.mjs"
|
|
58
|
+
}
|
|
6
59
|
}
|