dsh-smooth-scroll 0.1.1-rc.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/LICENSE +21 -0
- package/README.md +75 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +225 -0
- package/lib/index.js +6 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VinciBeans
|
|
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
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# dsh-smooth-scroll
|
|
2
|
+
|
|
3
|
+
## 简介
|
|
4
|
+
|
|
5
|
+
让 DSH 的会话滚底从"官方瞬时跳变"变成**流畅顺滑的跟随滚动**:会话装载与"回到最新"
|
|
6
|
+
保持瞬时(这是"跳"的正确语义),而流式内容增长期间,消息列以**恒定速度平滑跟随**,
|
|
7
|
+
起步轻柔、收尾绵软。
|
|
8
|
+
|
|
9
|
+
实现上**不触碰任何 transform**——直接对原生 `scrollTop` 做有节奏的动画,并用一个
|
|
10
|
+
**合成 getter** 在动画期间向 DSH 报告"目标值",因此它的跟随状态机(是否在底部、
|
|
11
|
+
是否用户滚动)完全不受影响:不会误判脱钩、不会重复钉底、输入栏等 UI 丝毫不被扰动。
|
|
12
|
+
系统开启"减少动效"(`prefers-reduced-motion`)时自动回退官方瞬时行为。
|
|
13
|
+
|
|
14
|
+
## 行为(最终架构:无 transform)
|
|
15
|
+
|
|
16
|
+
直接在**原生 scrollTop** 上做有节奏的平滑滚动,配合**合成 getter**:平滑期间
|
|
17
|
+
`scrollTop` 读到"目标值",DSH 自身的状态机(movedByReader/atBottom)永远看不到中间
|
|
18
|
+
位置——不会误判脱钩、不会重复钉底。
|
|
19
|
+
|
|
20
|
+
- **速度剖面(velocity chase + 缓启动 + 软尾)**
|
|
21
|
+
- 起步:0 → 0.9px/ms,240ms smoothstep 爬升;
|
|
22
|
+
- 巡航:恒速 0.9px/ms(内容增长只更新目标点,曲线永不逐段重启);
|
|
23
|
+
- 收尾:停止增长 >240ms 且剩余 ≤120px 时,220ms ease-out 软着陆;
|
|
24
|
+
- **同目标兜底写入**(DSH 每滚动事件重跑 `toBottom`,目标不变)→ 忽略,动画不被逐帧重启;
|
|
25
|
+
- **用户滚动接管**:真实位置偏离动画自身写入(唯一只能来自用户)→ 立即停动画、
|
|
26
|
+
getter 恢复真实、DSH 正常脱钩("回到最新"按钮照常);
|
|
27
|
+
- `prefers-reduced-motion: reduce`:完全回退官方瞬时行为;
|
|
28
|
+
- **不应用任何 transform**:输入栏 overlay/sticky 永不受扰(无瞬移、无毛边、无飞出)。
|
|
29
|
+
|
|
30
|
+
## 历史教训(为什么不是 transform/rAF 手写)
|
|
31
|
+
|
|
32
|
+
1. transform 补偿方案与 DSH 的输入栏 overlay 测量偶发交错 → 输入栏瞬移/毛边(已弃);
|
|
33
|
+
2. JS rAF 手写动画会冻结/重启问题(已弃)——最终版由 getter 掩盖中间态 + 恒速模型。
|
|
34
|
+
|
|
35
|
+
## 构建
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pnpm install # 安装 esbuild devDependency
|
|
39
|
+
pnpm run build # 产出 lib/index.js + lib/client.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`lib/` 已随仓库提交,克隆后可直接安装使用;改源码才需要重新构建。
|
|
43
|
+
|
|
44
|
+
## 安装(永久生效)
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bash install-real-profile.sh # Windows 用 Git Bash / WSL;Linux/macOS 直接 bash
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
脚本执行:`pnpm add link:<本目录>`(~/.dsh/profiles/web/package.json)+ 向
|
|
51
|
+
`cordis.patch.yml` 追加 `- insert: [{ id: smooth-scroll, name: 'dsh-smooth-scroll' }]`。
|
|
52
|
+
然后**重启 dsh web**。注意:源目录为 link 安装,改 `src/client.js` 后重建即可热更到下次启动。
|
|
53
|
+
|
|
54
|
+
## 卸载
|
|
55
|
+
|
|
56
|
+
删除 `cordis.patch.yml` 对应 insert 块 + `pnpm remove dsh-smooth-scroll`,重启。
|
|
57
|
+
|
|
58
|
+
## 调参
|
|
59
|
+
|
|
60
|
+
所有参数都在 `src/client.js` 顶部常量区:
|
|
61
|
+
|
|
62
|
+
| 常量 | 默认 | 含义 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `VEL` | 0.9 | 巡航速度(px/ms) |
|
|
65
|
+
| `RAMP_MS` | 240 | 起步缓加速时长(smoothstep) |
|
|
66
|
+
| `QUIET_MS` | 240 | 判定"停止增长"的静默窗口 |
|
|
67
|
+
| `TAIL_PX` | 120 | 进入软尾的剩余距离阈值 |
|
|
68
|
+
| `TAIL_MS` | 220 | 软尾缓动时长 |
|
|
69
|
+
|
|
70
|
+
改完:`pnpm run build` → 重启 dsh web。
|
|
71
|
+
|
|
72
|
+
## 源码
|
|
73
|
+
|
|
74
|
+
- `src/client.js` — 浏览器半部(核心逻辑)
|
|
75
|
+
- `src/index.ts` — 宿主半部(空 apply,仅为组合可见)
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-smooth-scroll", factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/client.js
|
|
21
|
+
var client_exports = {};
|
|
22
|
+
__export(client_exports, {
|
|
23
|
+
apply: () => apply
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(client_exports);
|
|
26
|
+
var VEL = 0.9;
|
|
27
|
+
var RAMP_MS = 240;
|
|
28
|
+
var QUIET_MS = 240;
|
|
29
|
+
var TAIL_PX = 120;
|
|
30
|
+
var TAIL_MS = 220;
|
|
31
|
+
var easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
|
32
|
+
var smooth = (t) => t * t * (3 - 2 * t);
|
|
33
|
+
var now = () => typeof performance === "object" && performance !== null && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
34
|
+
function apply(ctx) {
|
|
35
|
+
ctx.effect(() => {
|
|
36
|
+
const doc = document;
|
|
37
|
+
const root = doc.body ?? doc.documentElement;
|
|
38
|
+
if (typeof MutationObserver !== "function" || typeof requestAnimationFrame !== "function") return;
|
|
39
|
+
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
40
|
+
const states = /* @__PURE__ */ new Map();
|
|
41
|
+
let announced = false;
|
|
42
|
+
const readTop = (state) => state.chase || state.settle !== null ? state.target : state.desc.get.call(state.el);
|
|
43
|
+
const stopAll = (state) => {
|
|
44
|
+
state.chase = false;
|
|
45
|
+
state.settle = null;
|
|
46
|
+
state.rafId = 0;
|
|
47
|
+
};
|
|
48
|
+
const tick = (state, ts) => {
|
|
49
|
+
state.rafId = 0;
|
|
50
|
+
if (!state.el.isConnected) {
|
|
51
|
+
stopAll(state);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (state.lastTs === 0) state.lastTs = ts;
|
|
55
|
+
const dt = Math.min(50, ts - state.lastTs);
|
|
56
|
+
state.lastTs = ts;
|
|
57
|
+
if (state.settle !== null) {
|
|
58
|
+
const s = state.settle;
|
|
59
|
+
const t = Math.min(1, (ts - s.start) / s.ms);
|
|
60
|
+
const v2 = s.from + (s.to - s.from) * easeOut(t);
|
|
61
|
+
state.lastWrite = v2;
|
|
62
|
+
state.desc.set.call(state.el, v2);
|
|
63
|
+
if (t >= 1) {
|
|
64
|
+
state.settle = null;
|
|
65
|
+
state.lastTs = 0;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
state.rafId = requestAnimationFrame((t2) => tick(state, t2));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!state.chase || state.target === null) {
|
|
72
|
+
state.chase = false;
|
|
73
|
+
state.lastTs = 0;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const pos = state.desc.get.call(state.el);
|
|
77
|
+
const remaining = Math.max(0, state.target) - pos;
|
|
78
|
+
if (Math.abs(remaining) <= 0.5) {
|
|
79
|
+
state.desc.set.call(state.el, Math.max(0, state.target));
|
|
80
|
+
state.chase = false;
|
|
81
|
+
state.lastTs = 0;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (Math.abs(remaining) <= TAIL_PX && now() - state.lastPinTs > QUIET_MS) {
|
|
85
|
+
state.settle = { from: pos, to: state.target, start: ts, ms: TAIL_MS };
|
|
86
|
+
state.chase = false;
|
|
87
|
+
state.rafId = requestAnimationFrame((t2) => tick(state, t2));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const ramp = Math.min(1, Math.max(0, (ts - state.chaseStart) / RAMP_MS));
|
|
91
|
+
const v = VEL * smooth(ramp);
|
|
92
|
+
const step = Math.sign(remaining) * Math.min(Math.abs(remaining), v * dt);
|
|
93
|
+
const nv = pos + step;
|
|
94
|
+
state.lastWrite = nv;
|
|
95
|
+
state.desc.set.call(state.el, nv);
|
|
96
|
+
state.rafId = requestAnimationFrame((t2) => tick(state, t2));
|
|
97
|
+
};
|
|
98
|
+
const writeScrollTop = (state, desc, value) => {
|
|
99
|
+
const el = state.el;
|
|
100
|
+
const current = desc.get.call(el);
|
|
101
|
+
const max = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
102
|
+
const target = Math.min(value, max);
|
|
103
|
+
state.pinnedTop = target;
|
|
104
|
+
const isPin = value >= max - 1 && Math.abs(target - current) > 0.5;
|
|
105
|
+
if (!isPin) {
|
|
106
|
+
if (Math.abs(target - current) > 0.5) {
|
|
107
|
+
stopAll(state);
|
|
108
|
+
desc.set.call(el, value);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (reduced) {
|
|
113
|
+
desc.set.call(el, value);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const firstPin = state.firstPin;
|
|
117
|
+
state.firstPin = false;
|
|
118
|
+
const big = Math.abs(target - current) > state.el.clientHeight * 2;
|
|
119
|
+
if (firstPin || big) {
|
|
120
|
+
state.target = null;
|
|
121
|
+
state.chase = false;
|
|
122
|
+
state.settle = null;
|
|
123
|
+
desc.set.call(el, value);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
state.target = target;
|
|
127
|
+
state.settle = null;
|
|
128
|
+
state.lastPinTs = now();
|
|
129
|
+
if (!state.chase) {
|
|
130
|
+
state.chase = true;
|
|
131
|
+
state.chaseStart = now();
|
|
132
|
+
state.lastTs = 0;
|
|
133
|
+
state.rafId = requestAnimationFrame((t2) => tick(state, t2));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const attach = (el) => {
|
|
137
|
+
if (states.has(el) || typeof el.querySelector !== "function") return;
|
|
138
|
+
let proto = el;
|
|
139
|
+
let desc = null;
|
|
140
|
+
while (proto !== null) {
|
|
141
|
+
const candidate = Object.getOwnPropertyDescriptor(proto, "scrollTop");
|
|
142
|
+
if (candidate !== void 0) {
|
|
143
|
+
desc = candidate;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
proto = Object.getPrototypeOf(proto);
|
|
147
|
+
}
|
|
148
|
+
if (desc === null || typeof desc.get !== "function" || typeof desc.set !== "function") return;
|
|
149
|
+
const state = {
|
|
150
|
+
el,
|
|
151
|
+
desc,
|
|
152
|
+
flow: null,
|
|
153
|
+
pinnedTop: -1,
|
|
154
|
+
firstPin: true,
|
|
155
|
+
chase: false,
|
|
156
|
+
settle: null,
|
|
157
|
+
target: null,
|
|
158
|
+
lastPinTs: 0,
|
|
159
|
+
lastTs: 0,
|
|
160
|
+
rafId: 0,
|
|
161
|
+
lastWrite: 0,
|
|
162
|
+
chaseStart: 0
|
|
163
|
+
};
|
|
164
|
+
states.set(el, state);
|
|
165
|
+
Object.defineProperty(el, "scrollTop", {
|
|
166
|
+
configurable: true,
|
|
167
|
+
enumerable: false,
|
|
168
|
+
get() {
|
|
169
|
+
return readTop(state);
|
|
170
|
+
},
|
|
171
|
+
set(value) {
|
|
172
|
+
writeScrollTop(state, desc, value);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
if (!announced) {
|
|
176
|
+
announced = true;
|
|
177
|
+
console.log("[dsh-smooth-scroll] attached to the conversation scroller");
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const disposeState = (state) => {
|
|
181
|
+
stopAll(state);
|
|
182
|
+
if (Object.prototype.hasOwnProperty.call(state.el, "scrollTop")) {
|
|
183
|
+
try {
|
|
184
|
+
delete state.el.scrollTop;
|
|
185
|
+
} catch (e) {
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
state.flow = null;
|
|
189
|
+
states.delete(state.el);
|
|
190
|
+
};
|
|
191
|
+
const scan = () => {
|
|
192
|
+
for (const el of doc.querySelectorAll("*[data-conversation-scroll]")) attach(el);
|
|
193
|
+
for (const [el, state] of states) if (!el.isConnected) disposeState(state);
|
|
194
|
+
};
|
|
195
|
+
let scanQueued = false;
|
|
196
|
+
const queueScan = () => {
|
|
197
|
+
if (scanQueued) return;
|
|
198
|
+
scanQueued = true;
|
|
199
|
+
requestAnimationFrame(() => {
|
|
200
|
+
scanQueued = false;
|
|
201
|
+
scan();
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
const onDocScroll = (event) => {
|
|
205
|
+
const target = event.target;
|
|
206
|
+
if (target === null || target === void 0) return;
|
|
207
|
+
const state = states.get(target);
|
|
208
|
+
if (state === void 0) return;
|
|
209
|
+
const real = state.desc.get.call(state.el);
|
|
210
|
+
if ((state.chase || state.settle !== null) && Math.abs(real - state.lastWrite) > 0.5) {
|
|
211
|
+
stopAll(state);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
doc.addEventListener("scroll", onDocScroll, { capture: true, passive: true });
|
|
215
|
+
const observer = new MutationObserver(queueScan);
|
|
216
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
217
|
+
queueScan();
|
|
218
|
+
return () => {
|
|
219
|
+
observer.disconnect();
|
|
220
|
+
doc.removeEventListener("scroll", onDocScroll, true);
|
|
221
|
+
for (const state of [...states.values()]) disposeState(state);
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return module.exports; } });
|
package/lib/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-smooth-scroll",
|
|
3
|
+
"version": "0.1.1-rc.2",
|
|
4
|
+
"description": "DSH 平滑滚底插件:会话装载与“回到最新”瞬时钉底,流式内容以恒速平滑跟随(缓启动/软尾);无 transform、不扰动输入栏等 UI、不干扰 DSH 自身跟随状态机,prefers-reduced-motion 自动回退官方行为。适配 dsh-0.1.1-rc.2。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dsh": {
|
|
24
|
+
"client": {
|
|
25
|
+
"platform": "web"
|
|
26
|
+
},
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node scripts/build.mjs",
|
|
33
|
+
"prepublishOnly": "node scripts/build.mjs"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"esbuild": "^0.25.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"dsh",
|
|
40
|
+
"dsh-plugin",
|
|
41
|
+
"deepseek-harness",
|
|
42
|
+
"smooth-scroll",
|
|
43
|
+
"scroll"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|