dsh-smooth-scroll 0.1.1-rc.2 → 0.1.2-alpha.3
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 +71 -75
- package/cordis.patch.yml +1 -1
- package/lib/client.js +52 -38
- package/package.json +9 -11
package/README.md
CHANGED
|
@@ -1,75 +1,71 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
`
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
## 源码
|
|
73
|
-
|
|
74
|
-
- `src/client.js` — 浏览器半部(核心逻辑)
|
|
75
|
-
- `src/index.ts` — 宿主半部(空 apply,仅为组合可见)
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# dsh-smooth-scroll
|
|
4
|
+
|
|
5
|
+
**让 DSH 会话滚底从瞬时跳变变成恒速平滑跟随**
|
|
6
|
+
|
|
7
|
+
装上后,流式内容增长时消息列平滑跟随到底部,回到最新仍保持瞬时。
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/dsh-smooth-scroll">
|
|
11
|
+
<img src="https://img.shields.io/npm/v/dsh-smooth-scroll/alpha?style=flat&colorA=000000&colorB=000000" />
|
|
12
|
+
</a>
|
|
13
|
+
<a href="https://github.com/VinciBeans/dsh-smooth-plugin/blob/main/LICENSE">
|
|
14
|
+
<img src="https://img.shields.io/github/license/VinciBeans/dsh-smooth-plugin?style=flat&colorA=000000&colorB=000000" />
|
|
15
|
+
</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
从 npm 安装(`alpha` dist-tag 指向 0.1.2-alpha.3,适配 dsh v0.1.2-alpha.1 ~ alpha.3):
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
dsh plugin --profile web add dsh-smooth-scroll@alpha
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
需要已安装 DSH,并至少成功启动过一次 Web GUI。
|
|
29
|
+
|
|
30
|
+
npm `latest`(0.1.1-rc.2)仍是旧 client-runtime 代,仅适配 dsh 0.1.1-rc.2;安装它的命令为 `dsh plugin --profile web add dsh-smooth-scroll`。源码安装:`dsh plugin --profile web add .`。
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
dsh plugin --profile web add dsh-smooth-scroll@alpha
|
|
36
|
+
dsh --profile web --dump-config # 看到 dsh-smooth-scroll 层即安装成功
|
|
37
|
+
# 重启 dsh web,打开会话,流式内容平滑滚到底部
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 滚动行为
|
|
41
|
+
|
|
42
|
+
直接在原生 `scrollTop` 上做有节奏的平滑滚动,配合合成 getter:平滑期间 `scrollTop` 读到目标值,DSH 自身状态机看不到中间位置,不误判脱钩、不重复钉底。
|
|
43
|
+
|
|
44
|
+
- **速度剖面:** 起步 0 到 0.9px/ms 用 240ms 缓升,巡航恒速 0.9px/ms,收尾 220ms 软着陆。
|
|
45
|
+
- **用户滚动接管:** 真实位置连续偏离动画写入 ≥2 帧才停动画,DSH 正常脱钩,回到最新按钮照常;单帧偏差(浏览器滚动锚定等一次性非用户位移)自动重基后继续钉底跟随——发送消息等操作引发的非用户位移不会导致误判脱钩或停在信息处;内容收窄导致的夹紧则在 2 帧内停止于新底部(同样不掉队)。与之对称,孤立的一次性小幅定位(如滚动条点一格)也会被当作非用户位移吸收、随后被跟随回底部;只有持续 ≥2 帧的偏离才视为用户接管。另:主机内(输入栏除外)的**指针按下立即停动画**并让 getter 回落真实值——alpha.3 的 turn 导轨跳转(`landOnRow` 复合读改写)由此读到真实位置,落点不被合成目标偏移。
|
|
46
|
+
- **同目标兜底:** DSH 每滚动事件重跑 toBottom 而目标不变时忽略,动画不被逐帧重启。
|
|
47
|
+
- **减少动效:** prefers-reduced-motion 时完全回退官方瞬时行为。
|
|
48
|
+
- **无 transform:** 不应用任何 transform,输入栏 overlay/sticky 永不受扰。
|
|
49
|
+
|
|
50
|
+
## 参数
|
|
51
|
+
|
|
52
|
+
| 常量 | 默认 | 含义 |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| VEL | 0.9 | 巡航速度(px/ms) |
|
|
55
|
+
| RAMP_MS | 240 | 起步缓加速时长 |
|
|
56
|
+
| QUIET_MS | 240 | 判定停止增长的静默窗口 |
|
|
57
|
+
| TAIL_PX | 120 | 进入软尾的剩余距离阈值 |
|
|
58
|
+
| TAIL_MS | 220 | 软尾缓动时长 |
|
|
59
|
+
| DIVERGE_STOP_FRAMES | 2 | 真实位置连续偏离动画写入的帧数阈值(≥2 帧判定读者接管) |
|
|
60
|
+
|
|
61
|
+
改参数:编辑 `src/client.js` 顶部常量区,`pnpm run build` 后重启 dsh web。
|
|
62
|
+
|
|
63
|
+
## 兼容性
|
|
64
|
+
|
|
65
|
+
- **dsh v0.1.2-alpha.1 ~ alpha.3(支持):** 插件的唯一 DOM 锚点 `[data-conversation-scroll]`(会话滚动容器)与宿主跟随状态机(`observedTopRef` / `movedByReader` / ResizeObserver follow、`el.scrollTop` 读写面)在 `dsh-v0.1.2-alpha.1`、`dsh-v0.1.2-alpha.2`、`dsh-v0.1.2-alpha.3` 三个 tag 上一致——alpha.3 只在滚动容器外多包了一层 `.body`,属性与滚动语义未变。alpha.3 新增的 turn 导轨跳转(`landOnRow` 的 `el.scrollTop += flowTop - 24` 复合读改写)遇到插件追击时会被 pointerdown 接管先停动画,复合写读到真实位置,落点不被合成 getter 偏移;该接管在三个版本上同样生效。
|
|
66
|
+
- **0.1.1-rc.2 及更早(不支持):** 该代使用 `@deepseek-ai/dsh-client-runtime`,滚动宿主结构不同,不兼容。
|
|
67
|
+
- 验证:`pnpm test`(契约冒烟,bundle 自包含)+ `node test/scroll-follow.test.mjs`(15 个 e2e 场景,含「追击中点击导轨」回归点;需 Playwright 与 Chromium)。
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
package/cordis.patch.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh 组合包层(bundle layer):把 dsh-smooth-scroll 插入 web 配置树。
|
|
2
2
|
#
|
|
3
|
-
# `name` 必须是包名(而非源码相对路径);
|
|
3
|
+
# `name` 必须是包名(而非源码相对路径);client-modules 节点半部扫描到这一行,
|
|
4
4
|
# 读到 dsh.client 声明后把浏览器 bundle 挂到 /plugins/dsh-smooth-scroll/client.js。
|
|
5
5
|
- insert:
|
|
6
6
|
- id: 'smooth-scroll'
|
package/lib/client.js
CHANGED
|
@@ -28,6 +28,7 @@ var RAMP_MS = 240;
|
|
|
28
28
|
var QUIET_MS = 240;
|
|
29
29
|
var TAIL_PX = 120;
|
|
30
30
|
var TAIL_MS = 220;
|
|
31
|
+
var DIVERGE_STOP_FRAMES = 2;
|
|
31
32
|
var easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
|
32
33
|
var smooth = (t) => t * t * (3 - 2 * t);
|
|
33
34
|
var now = () => typeof performance === "object" && performance !== null && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
@@ -38,15 +39,13 @@ function apply(ctx) {
|
|
|
38
39
|
if (typeof MutationObserver !== "function" || typeof requestAnimationFrame !== "function") return;
|
|
39
40
|
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
40
41
|
const states = /* @__PURE__ */ new Map();
|
|
41
|
-
let announced = false;
|
|
42
42
|
const readTop = (state) => state.chase || state.settle !== null ? state.target : state.desc.get.call(state.el);
|
|
43
43
|
const stopAll = (state) => {
|
|
44
44
|
state.chase = false;
|
|
45
45
|
state.settle = null;
|
|
46
|
-
state.
|
|
46
|
+
state.farN = 0;
|
|
47
47
|
};
|
|
48
48
|
const tick = (state, ts) => {
|
|
49
|
-
state.rafId = 0;
|
|
50
49
|
if (!state.el.isConnected) {
|
|
51
50
|
stopAll(state);
|
|
52
51
|
return;
|
|
@@ -54,6 +53,27 @@ function apply(ctx) {
|
|
|
54
53
|
if (state.lastTs === 0) state.lastTs = ts;
|
|
55
54
|
const dt = Math.min(50, ts - state.lastTs);
|
|
56
55
|
state.lastTs = ts;
|
|
56
|
+
const pos = state.desc.get.call(state.el);
|
|
57
|
+
if (state.settle === null && !state.chase) {
|
|
58
|
+
state.farN = 0;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (Math.abs(pos - state.lastWrite) > 0.5) {
|
|
62
|
+
state.farN += 1;
|
|
63
|
+
if (state.farN >= DIVERGE_STOP_FRAMES) {
|
|
64
|
+
state.farN = 0;
|
|
65
|
+
stopAll(state);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
state.lastWrite = pos;
|
|
69
|
+
if (state.settle !== null) {
|
|
70
|
+
state.settle = null;
|
|
71
|
+
state.chase = true;
|
|
72
|
+
state.chaseStart = ts;
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
state.farN = 0;
|
|
76
|
+
}
|
|
57
77
|
if (state.settle !== null) {
|
|
58
78
|
const s = state.settle;
|
|
59
79
|
const t = Math.min(1, (ts - s.start) / s.ms);
|
|
@@ -65,15 +85,14 @@ function apply(ctx) {
|
|
|
65
85
|
state.lastTs = 0;
|
|
66
86
|
return;
|
|
67
87
|
}
|
|
68
|
-
|
|
88
|
+
requestAnimationFrame((t2) => tick(state, t2));
|
|
69
89
|
return;
|
|
70
90
|
}
|
|
71
|
-
if (!state.chase
|
|
91
|
+
if (!state.chase) {
|
|
72
92
|
state.chase = false;
|
|
73
93
|
state.lastTs = 0;
|
|
74
94
|
return;
|
|
75
95
|
}
|
|
76
|
-
const pos = state.desc.get.call(state.el);
|
|
77
96
|
const remaining = Math.max(0, state.target) - pos;
|
|
78
97
|
if (Math.abs(remaining) <= 0.5) {
|
|
79
98
|
state.desc.set.call(state.el, Math.max(0, state.target));
|
|
@@ -84,7 +103,7 @@ function apply(ctx) {
|
|
|
84
103
|
if (Math.abs(remaining) <= TAIL_PX && now() - state.lastPinTs > QUIET_MS) {
|
|
85
104
|
state.settle = { from: pos, to: state.target, start: ts, ms: TAIL_MS };
|
|
86
105
|
state.chase = false;
|
|
87
|
-
|
|
106
|
+
requestAnimationFrame((t2) => tick(state, t2));
|
|
88
107
|
return;
|
|
89
108
|
}
|
|
90
109
|
const ramp = Math.min(1, Math.max(0, (ts - state.chaseStart) / RAMP_MS));
|
|
@@ -93,19 +112,23 @@ function apply(ctx) {
|
|
|
93
112
|
const nv = pos + step;
|
|
94
113
|
state.lastWrite = nv;
|
|
95
114
|
state.desc.set.call(state.el, nv);
|
|
96
|
-
|
|
115
|
+
requestAnimationFrame((t2) => tick(state, t2));
|
|
97
116
|
};
|
|
98
117
|
const writeScrollTop = (state, desc, value) => {
|
|
99
118
|
const el = state.el;
|
|
100
|
-
const current = desc.get.call(el);
|
|
101
119
|
const max = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
120
|
+
const current = desc.get.call(el);
|
|
102
121
|
const target = Math.min(value, max);
|
|
103
|
-
|
|
104
|
-
const isPin =
|
|
122
|
+
const pinIntent = value >= max - 1;
|
|
123
|
+
const isPin = pinIntent && Math.abs(target - current) > 0.5;
|
|
105
124
|
if (!isPin) {
|
|
106
125
|
if (Math.abs(target - current) > 0.5) {
|
|
107
126
|
stopAll(state);
|
|
108
127
|
desc.set.call(el, value);
|
|
128
|
+
} else if (pinIntent && (state.chase || state.settle !== null)) {
|
|
129
|
+
state.target = target;
|
|
130
|
+
stopAll(state);
|
|
131
|
+
state.lastWrite = current;
|
|
109
132
|
}
|
|
110
133
|
return;
|
|
111
134
|
}
|
|
@@ -130,7 +153,9 @@ function apply(ctx) {
|
|
|
130
153
|
state.chase = true;
|
|
131
154
|
state.chaseStart = now();
|
|
132
155
|
state.lastTs = 0;
|
|
133
|
-
state.
|
|
156
|
+
state.lastWrite = current;
|
|
157
|
+
state.farN = 0;
|
|
158
|
+
requestAnimationFrame((t2) => tick(state, t2));
|
|
134
159
|
}
|
|
135
160
|
};
|
|
136
161
|
const attach = (el) => {
|
|
@@ -149,18 +174,25 @@ function apply(ctx) {
|
|
|
149
174
|
const state = {
|
|
150
175
|
el,
|
|
151
176
|
desc,
|
|
152
|
-
flow: null,
|
|
153
|
-
pinnedTop: -1,
|
|
154
177
|
firstPin: true,
|
|
155
178
|
chase: false,
|
|
156
179
|
settle: null,
|
|
157
180
|
target: null,
|
|
158
181
|
lastPinTs: 0,
|
|
159
182
|
lastTs: 0,
|
|
160
|
-
rafId: 0,
|
|
161
183
|
lastWrite: 0,
|
|
162
|
-
chaseStart: 0
|
|
184
|
+
chaseStart: 0,
|
|
185
|
+
farN: 0
|
|
186
|
+
};
|
|
187
|
+
const stopOnPointerDown = (event) => {
|
|
188
|
+
if (!state.chase && state.settle === null) return;
|
|
189
|
+
const target = event.target;
|
|
190
|
+
if (!(target instanceof Element)) return;
|
|
191
|
+
if (target.closest("[data-composer-seat]") !== null) return;
|
|
192
|
+
stopAll(state);
|
|
163
193
|
};
|
|
194
|
+
state.stopOnPointerDown = stopOnPointerDown;
|
|
195
|
+
el.addEventListener("pointerdown", stopOnPointerDown, true);
|
|
164
196
|
states.set(el, state);
|
|
165
197
|
Object.defineProperty(el, "scrollTop", {
|
|
166
198
|
configurable: true,
|
|
@@ -172,20 +204,14 @@ function apply(ctx) {
|
|
|
172
204
|
writeScrollTop(state, desc, value);
|
|
173
205
|
}
|
|
174
206
|
});
|
|
175
|
-
if (!announced) {
|
|
176
|
-
announced = true;
|
|
177
|
-
console.log("[dsh-smooth-scroll] attached to the conversation scroller");
|
|
178
|
-
}
|
|
179
207
|
};
|
|
180
208
|
const disposeState = (state) => {
|
|
181
209
|
stopAll(state);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
210
|
+
state.el.removeEventListener("pointerdown", state.stopOnPointerDown, true);
|
|
211
|
+
const scrollDesc = Object.getOwnPropertyDescriptor(state.el, "scrollTop");
|
|
212
|
+
if (scrollDesc !== void 0 && scrollDesc.configurable) {
|
|
213
|
+
delete state.el.scrollTop;
|
|
187
214
|
}
|
|
188
|
-
state.flow = null;
|
|
189
215
|
states.delete(state.el);
|
|
190
216
|
};
|
|
191
217
|
const scan = () => {
|
|
@@ -201,23 +227,11 @@ function apply(ctx) {
|
|
|
201
227
|
scan();
|
|
202
228
|
});
|
|
203
229
|
};
|
|
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
230
|
const observer = new MutationObserver(queueScan);
|
|
216
231
|
observer.observe(root, { childList: true, subtree: true });
|
|
217
232
|
queueScan();
|
|
218
233
|
return () => {
|
|
219
234
|
observer.disconnect();
|
|
220
|
-
doc.removeEventListener("scroll", onDocScroll, true);
|
|
221
235
|
for (const state of [...states.values()]) disposeState(state);
|
|
222
236
|
};
|
|
223
237
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-scroll",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "DSH 平滑滚底插件:会话装载与“回到最新”瞬时钉底,流式内容以恒速平滑跟随(缓启动/软尾);无 transform、不扰动输入栏等 UI、不干扰 DSH 自身跟随状态机,prefers-reduced-motion 自动回退官方行为。适配 dsh-
|
|
3
|
+
"version": "0.1.2-alpha.3",
|
|
4
|
+
"description": "DSH 平滑滚底插件:会话装载与“回到最新”瞬时钉底,流式内容以恒速平滑跟随(缓启动/软尾);无 transform、不扰动输入栏等 UI、不干扰 DSH 自身跟随状态机,prefers-reduced-motion 自动回退官方行为。适配 dsh-v0.1.2-alpha.1 ~ alpha.3。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -17,9 +17,6 @@
|
|
|
17
17
|
"lib",
|
|
18
18
|
"cordis.patch.yml"
|
|
19
19
|
],
|
|
20
|
-
"publishConfig": {
|
|
21
|
-
"access": "public"
|
|
22
|
-
},
|
|
23
20
|
"dsh": {
|
|
24
21
|
"client": {
|
|
25
22
|
"platform": "web"
|
|
@@ -28,10 +25,6 @@
|
|
|
28
25
|
"patch": "./cordis.patch.yml"
|
|
29
26
|
}
|
|
30
27
|
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "node scripts/build.mjs",
|
|
33
|
-
"prepublishOnly": "node scripts/build.mjs"
|
|
34
|
-
},
|
|
35
28
|
"devDependencies": {
|
|
36
29
|
"esbuild": "^0.25.0"
|
|
37
30
|
},
|
|
@@ -42,5 +35,10 @@
|
|
|
42
35
|
"smooth-scroll",
|
|
43
36
|
"scroll"
|
|
44
37
|
],
|
|
45
|
-
"license": "MIT"
|
|
46
|
-
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "node scripts/build.mjs",
|
|
41
|
+
"test": "node test/smoke.test.mjs",
|
|
42
|
+
"test:e2e": "node test/scroll-follow.test.mjs"
|
|
43
|
+
}
|
|
44
|
+
}
|