motion-hub 0.1.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.
Files changed (29) hide show
  1. package/LICENSE +44 -0
  2. package/README.md +141 -0
  3. package/bin/install-skill.mjs +39 -0
  4. package/package.json +48 -0
  5. package/skill/motion-hub/SKILL.md +127 -0
  6. package/skill/motion-hub/effects/contour.html +279 -0
  7. package/skill/motion-hub/effects/dots-quiet.html +257 -0
  8. package/skill/motion-hub/effects/dots.html +263 -0
  9. package/skill/motion-hub/effects/flow.html +253 -0
  10. package/skill/motion-hub/effects/follow-tip.html +241 -0
  11. package/skill/motion-hub/effects/glow-card.html +236 -0
  12. package/skill/motion-hub/effects/grid.html +266 -0
  13. package/skill/motion-hub/effects/intro.html +302 -0
  14. package/skill/motion-hub/effects/pipeline.html +257 -0
  15. package/skill/motion-hub/effects/pull.html +373 -0
  16. package/skill/motion-hub/effects/scramble.html +248 -0
  17. package/skill/motion-hub/effects/sun-moon.html +235 -0
  18. package/skill/motion-hub/effects/theme-blinds.html +381 -0
  19. package/skill/motion-hub/effects/theme-circle.html +381 -0
  20. package/skill/motion-hub/effects/theme-dots.html +381 -0
  21. package/skill/motion-hub/effects/theme-fade.html +381 -0
  22. package/skill/motion-hub/effects/theme-flip.html +381 -0
  23. package/skill/motion-hub/effects/theme-halftone.html +381 -0
  24. package/skill/motion-hub/effects/theme-ink.html +381 -0
  25. package/skill/motion-hub/effects/theme-iris.html +381 -0
  26. package/skill/motion-hub/effects/theme-slice.html +381 -0
  27. package/skill/motion-hub/effects/theme-soft.html +381 -0
  28. package/skill/motion-hub/effects/theme-wave.html +381 -0
  29. package/skill/motion-hub/index.json +667 -0
@@ -0,0 +1,257 @@
1
+ <!doctype html>
2
+ <!--
3
+ 「校验链」 · Motion Hub · MIT + Commons Clause(可免费用于任何项目,不能售卖效果本身)
4
+
5
+ 【怎么用】
6
+ 这个文件可以直接双击打开,先看效果。
7
+ 想放进自己的项目:把整个文件交给 AI(Claude / ChatGPT / Cursor 等),再说一句你的技术栈和想要的调整,
8
+ 例如“把这个效果改写成 React 组件,放在首屏当背景,颜色换成我的品牌色”。下面的说明就是写给它看的。
9
+
10
+ 【效果】
11
+ 一次操作依次经过多道检查:小点沿线前进,在每道关前减速,通过后那道关点亮,到终点停一下再重来。适合解释流程或状态机。
12
+
13
+ 【实现原理】
14
+ - SVG 按容器实际宽度排布关卡(不是整张缩放),手机上字也保持 13px。
15
+ - 进度 raw 从 0 到 N,整数部分是第几道关,小数部分用 easeInOut,让小点在每道关前减速。
16
+ - 走过的关点亮(填强调色);一轮 7 秒,最后 20% 时间停在终点。
17
+
18
+ 【可以调的参数】
19
+ - 一轮 7s
20
+ - 关卡名可传入
21
+
22
+ 【注意】
23
+ - 窗口尺寸变化时重新排布(ResizeObserver)。
24
+ - 颜色都来自 CSS 变量(paper / ink / dim / rule / accent),换成项目自己的颜色即可。
25
+ - 要处理 prefers-reduced-motion:系统要求减少动态效果时只显示静止画面。
26
+ - 组件卸载时要停掉动画循环、移除事件监听(mount 返回的 destroy)。
27
+
28
+ 【opts 参数】(mount 的第二个参数,不传就用默认值;文件末尾的 mount 调用里是你在 Motion Hub 上调好的值)
29
+ - gates:关卡(一行一个),默认 "计划\n身份\n权限\n状态\n幂等\n限流\n审计"
30
+ - period:一轮时长,默认 7000(2000–15000ms)
31
+
32
+ 【代码结构】
33
+ - <style>:配色变量和这个效果用到的样式
34
+ - 第一段 <script>:MH 核心(注册表、噪声、缓动;canvasHost 负责画布尺寸、指针坐标、动画循环和暂停)
35
+ - 第二段 <script>:效果本身,核心是 MH.register 里的 mount(el, opts),返回 { pause, resume, destroy }
36
+ - 最后一段 <script>:把效果挂到 #fx 上并启动
37
+ -->
38
+ <html lang="zh-CN">
39
+ <head>
40
+ <meta charset="utf-8">
41
+ <meta name="viewport" content="width=device-width,initial-scale=1">
42
+ <title>校验链</title>
43
+ <style>
44
+ /* Motion Hub 效果样式:配色变量(Acid:浅色暖灰 + 紫罗兰,深色近黑 + 酸橙绿)+ 各效果用到的样式 + 整页过渡遮罩。按“@css 名称”标记分段,复制代码时只带用到的段。 */
45
+ :root{
46
+ --paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E;
47
+ --serif:"Songti SC","STSong","Noto Serif SC",serif;
48
+ --sans:"PingFang SC","Helvetica Neue","Noto Sans SC",sans-serif;
49
+ --latin:"Iowan Old Style",Palatino,Georgia,serif;
50
+ --mono:"SF Mono",Menlo,monospace;
51
+ }
52
+ [data-theme="night"]{--paper:#111113;--paper2:#1B1B1F;--ink:#F2F1F5;--body:#C4C2CC;--dim:#7E7B88;--rule:#2A2A30;--accent:#C6F21E;--on-accent:#111113;--hi:#8E75FF}
53
+ [data-theme="day"]{--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E}
54
+
55
+ /* 校验链 */
56
+ .pl{position:absolute;left:8cqw;right:8cqw;top:50%;transform:translateY(-50%)}
57
+ .pl svg{display:block;width:100%;height:90px;overflow:visible}
58
+ .pl .ln{stroke:var(--rule);stroke-width:2}
59
+ .pl .lnon{stroke:var(--accent);stroke-width:2}
60
+ .pl .g{fill:var(--paper);stroke:var(--dim);stroke-width:1.5;transition:fill .25s,stroke .25s}
61
+ .pl .g.on{fill:var(--accent);stroke:var(--accent)}
62
+ .pl text{font:500 13px var(--sans);fill:var(--dim);text-anchor:middle;transition:fill .25s}
63
+ .pl text.on{fill:var(--ink)}
64
+ .pl .tok{fill:var(--ink)}
65
+ html,body{height:100%;margin:0;background:var(--paper)}
66
+ #fx{position:relative;height:100vh;overflow:hidden;container-type:size}
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div id="fx"></div>
71
+ <script>
72
+ // Motion Hub 核心:注册表 + 公共工具。所有效果文件都是普通 <script>(不是 ES 模块),
73
+ // 这样直接双击 index.html(file://)也能运行。每个效果调用 MH.register({...}) 把自己登记进来。
74
+ //
75
+ // 效果对象的约定:
76
+ // id 唯一标识(小写短横线)
77
+ // name 中文名
78
+ // cat 分类:背景 / 过渡 / 翻页 / 片头 / 交互
79
+ // tech 用到的技术(展示用)
80
+ // desc 一句话说明
81
+ // mount(el, opts) → { pause(), resume(), destroy() }
82
+ // el 是一个已经有尺寸的容器;opts.expanded 为 true 表示全屏体验(关掉幽灵光标、允许滚轮等)
83
+ // params 可调参数(可选),实验台据此生成参数面板,每项 { k, label, type, def, ... }:
84
+ // type 'range':数值滑块,带 min / max / step,可选 unit;'text':单行文字;'lines':多行文字,一行一项
85
+ // mount 拿到的 opts 已经按 def 补齐(register 里包了一层),效果代码直接读 opts.k 即可
86
+ window.MH = (() => {
87
+ const effects = [];
88
+ // 记下效果来自哪个文件(复制代码时只带这个文件)
89
+ const register = e => {
90
+ const src = document.currentScript && document.currentScript.src; e.file = e.file || (src ? src.split('/').pop() : '');
91
+ if (e.params) { const m = e.mount; e.mount = (el, opts = {}) => m.call(e, el, { ...defaults(e), ...opts }); }
92
+ effects.push(e);
93
+ };
94
+ const defaults = e => Object.fromEntries((e.params || []).map(p => [p.k, p.def]));
95
+ // 'lines' 参数:数组原样返回;字符串按行拆开,去掉空行
96
+ const lines = v => Array.isArray(v) ? v : String(v || '').split('\n').map(x => x.trim()).filter(Boolean);
97
+ const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
98
+ const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
99
+ const lerp = (a, b, k) => a + (b - a) * k;
100
+ const ease = {
101
+ in: t => t * t * t,
102
+ out: t => 1 - (1 - t) ** 3,
103
+ inOut: t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
104
+ back: t => { const c = 1.4; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2; },
105
+ };
106
+ const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
107
+
108
+ // 二维 Perlin 噪声,seed 决定排列表
109
+ function perlin(seed = 1) {
110
+ const P = new Uint8Array(512), p = [...Array(256).keys()];
111
+ let s = seed;
112
+ for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [p[i], p[j]] = [p[j], p[i]]; }
113
+ for (let i = 0; i < 512; i++) P[i] = p[i & 255];
114
+ const fade = t => t * t * t * (t * (t * 6 - 15) + 10), grad = (h, x, y) => ((h & 1) ? -x : x) + ((h & 2) ? -y : y);
115
+ return (x, y) => {
116
+ const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
117
+ const u = fade(x), v = fade(y), a = P[X] + Y, b = P[X + 1] + Y;
118
+ const l1 = grad(P[a], x, y) + u * (grad(P[b], x - 1, y) - grad(P[a], x, y));
119
+ const l2 = grad(P[a + 1], x, y - 1) + u * (grad(P[b + 1], x - 1, y - 1) - grad(P[a + 1], x, y - 1));
120
+ return l1 + v * (l2 - l1);
121
+ };
122
+ }
123
+
124
+ // 画布按设备像素比放大,画的时候仍用 CSS 像素坐标
125
+ function fit(cv, w, h) {
126
+ const dpr = Math.min(devicePixelRatio || 1, 2);
127
+ cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
128
+ const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;
129
+ }
130
+ // 从容器读主题色(--paper / --ink / --dim / --rule / --accent)
131
+ function colors(el) {
132
+ const cs = getComputedStyle(el), v = n => cs.getPropertyValue(n).trim();
133
+ const t = el.closest('[data-theme]');
134
+ return { paper: v('--paper'), ink: v('--ink'), dim: v('--dim'), rule: v('--rule'), accent: v('--accent'), night: !!t && t.dataset.theme === 'night' };
135
+ }
136
+ // 一段时长 ms 的补间,fn(k) 中 k 从 0 到 1;减少动态效果时直接到终点
137
+ const tween = (ms, fn) => new Promise(res => {
138
+ if (still()) { fn(1); return res(); }
139
+ const t0 = performance.now();
140
+ const f = now => { const k = clamp((now - t0) / ms); fn(k); k < 1 ? requestAnimationFrame(f) : res(); };
141
+ requestAnimationFrame(f);
142
+ });
143
+ // 尺寸缩放系数:全屏约 1,缩略图约 0.45,让小画面像“整页缩小”而不是挤满细节
144
+ const scaleOf = W => clamp(W / 1200, .45, 1);
145
+
146
+ // 幽灵光标:没人操作时,沿一条利萨如曲线在容器里慢慢移动,让缩略图自己演示交互
147
+ const ghostAt = (now, W, H, seed = 0) => {
148
+ const t = now / 1000 + seed * 7;
149
+ return { x: W * (.5 + .32 * Math.sin(t * .55) * Math.cos(t * .21)), y: H * (.5 + .3 * Math.sin(t * .83 + 1.3)) };
150
+ };
151
+
152
+ // 效果宿主:(可选)建画布、跟随尺寸、换算局部指针坐标、管动画循环和暂停。
153
+ // setup(h) 返回 { resize(W,H), frame(now), down(x,y), theme(), ghostClick },h 提供 ctx / W / H / P / s / colors()
154
+ // ghostClick: false 表示幽灵光标不替它“点击”(例如片头,点一下会重播)
155
+ function canvasHost(el, opts, setup, { canvas = true } = {}) {
156
+ const cv = canvas ? document.createElement('canvas') : null;
157
+ if (cv) { cv.className = 'mh-canvas'; el.append(cv); }
158
+ let ctx, W = 0, H = 0, raf = 0, running = false, lastReal = -1e9, lastGhostClick = 0;
159
+ const P = { x: -9999, y: -9999, real: false };
160
+ const seed = hash(effects.length + el.childElementCount);
161
+ const h = { cv, get ctx() { return ctx; }, get W() { return W; }, get H() { return H; }, P, s: 1, colors: () => colors(el), expanded: !!opts.expanded };
162
+ const api = setup(h);
163
+ const size = () => {
164
+ const r = el.getBoundingClientRect(); if (!r.width || !r.height) return;
165
+ W = r.width; H = r.height; h.s = scaleOf(W); if (cv) ctx = fit(cv, W, H); api.resize && api.resize(W, H);
166
+ if (!running) api.frame(performance.now());
167
+ };
168
+ const ro = new ResizeObserver(size); ro.observe(el);
169
+ const local = e => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
170
+ const onMove = e => { [P.x, P.y] = local(e); P.real = true; lastReal = performance.now(); };
171
+ const onLeave = () => { P.real = false; P.x = P.y = -9999; };
172
+ const onDown = e => { if (e.target.closest('a, button')) return; lastReal = performance.now(); const [x, y] = local(e); api.down && api.down(x, y); };
173
+ el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);
174
+ const mo = new MutationObserver(() => { api.theme && api.theme(); if (!running) api.frame(performance.now()); });
175
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-palette'] });
176
+ const tick = now => {
177
+ if (!running) return;
178
+ // 缩略图里 2.5 秒没人碰,就让幽灵光标接管;偶尔“点”一下展示点击效果
179
+ if (!h.expanded && now - lastReal > 2500) {
180
+ const g = ghostAt(now, W, H, seed); P.x = g.x; P.y = g.y;
181
+ if (api.down && api.ghostClick !== false && now - lastGhostClick > 4200) { lastGhostClick = now; api.down(g.x, g.y); }
182
+ }
183
+ if (W && H) api.frame(now);
184
+ raf = requestAnimationFrame(tick);
185
+ };
186
+ size();
187
+ return {
188
+ pause() { running = false; cancelAnimationFrame(raf); },
189
+ resume() { if (running) return; if (still()) { api.frame(performance.now()); return; } running = true; raf = requestAnimationFrame(tick); },
190
+ destroy() { this.pause(); ro.disconnect(); mo.disconnect(); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); cv && cv.remove(); },
191
+ };
192
+ }
193
+
194
+ // 让 CSS 能对自定义属性做补间(遮罩半径等)
195
+ if (window.CSS && CSS.registerProperty) {
196
+ for (const name of ['--vr', '--vw']) { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch (e) { /* 已注册 */ } }
197
+ }
198
+
199
+ return { effects, register, defaults, lines, still, clamp, lerp, ease, hash, perlin, fit, colors, tween, scaleOf, ghostAt, canvasHost };
200
+ })();
201
+ </script>
202
+ <script>
203
+ // ───── 校验链:一个小点依次走过各道关,走过的关点亮,到终点停一下再重来 ─────
204
+ MH.register({
205
+ id: 'pipeline', name: '校验链', cat: '交互', tech: 'SVG · Staged Easing',
206
+ desc: '一次操作依次经过多道检查:小点在每道关前减速、通过后点亮,适合解释流程或状态机。',
207
+ params: [
208
+ { k: 'gates', label: '关卡(一行一个)', type: 'lines', def: '计划\n身份\n权限\n状态\n幂等\n限流\n审计' },
209
+ { k: 'period', label: '一轮时长', type: 'range', min: 2000, max: 15000, step: 250, def: 7000, unit: 'ms' },
210
+ ],
211
+ mount(el, opts) {
212
+ const G = MH.lines(opts.gates).length > 1 ? MH.lines(opts.gates) : ['开始', '结束'];
213
+ el.insertAdjacentHTML('beforeend', `<div class="pl"><svg></svg></div>`);
214
+ const box = el.lastElementChild, svg = box.querySelector('svg');
215
+ let gates, labels, pl, tk, x;
216
+ const build = () => {
217
+ const W = Math.max(box.clientWidth, 240), gap = (W - 60) / (G.length - 1), y = 40;
218
+ x = i => 30 + i * gap; svg.setAttribute('viewBox', `0 0 ${W} 90`);
219
+ svg.innerHTML = `<line class="ln" x1="${x(0)}" y1="${y}" x2="${x(G.length - 1)}" y2="${y}"/><line class="lnon" x1="${x(0)}" y1="${y}" x2="${x(0)}" y2="${y}"/>` +
220
+ G.map((g, i) => `<rect class="g" x="${x(i) - 8}" y="${y - 8}" width="16" height="16" ${i === 0 || i === G.length - 1 ? 'rx="8"' : ''}/><text x="${x(i)}" y="${y + 36}">${g}</text>`).join('') +
221
+ `<circle class="tok" cx="${x(0)}" cy="${y}" r="5"/>`;
222
+ gates = [...svg.querySelectorAll('.g')]; labels = [...svg.querySelectorAll('text')]; pl = svg.querySelector('.lnon'); tk = svg.querySelector('.tok');
223
+ };
224
+ const ro = new ResizeObserver(build); ro.observe(box); build();
225
+ const set = p => { const px = x(0) + p * (x(1) - x(0)); pl.setAttribute('x2', px); tk.setAttribute('cx', px); gates.forEach((g, i) => { g.classList.toggle('on', p >= i - .02); labels[i].classList.toggle('on', p >= i - .02); }); };
226
+ let running = false, raf = 0;
227
+ const N = G.length - 1, T = opts.period;
228
+ const loop = now => {
229
+ if (!running) return;
230
+ const raw = Math.min((now % T) / T * 1.25, 1) * N, i = Math.floor(raw), f = raw - i;
231
+ set(Math.min(i + (f < .5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2), N));
232
+ raf = requestAnimationFrame(loop);
233
+ };
234
+ return {
235
+ pause() { running = false; cancelAnimationFrame(raf); },
236
+ resume() { if (running) return; if (MH.still()) return set(N); running = true; raf = requestAnimationFrame(loop); },
237
+ destroy() { this.pause(); ro.disconnect(); box.remove(); },
238
+ };
239
+ },
240
+ });
241
+ </script>
242
+ <script>
243
+ MH.effects.find(e => e.id === 'pipeline').mount(document.getElementById('fx'), {
244
+ "gates": [
245
+ "计划",
246
+ "身份",
247
+ "权限",
248
+ "状态",
249
+ "幂等",
250
+ "限流",
251
+ "审计"
252
+ ],
253
+ "period": 7000
254
+ }).resume();
255
+ </script>
256
+ </body>
257
+ </html>
@@ -0,0 +1,373 @@
1
+ <!doctype html>
2
+ <!--
3
+ 「拉扯翻页」 · Motion Hub · MIT + Commons Clause(可免费用于任何项目,不能售卖效果本身)
4
+
5
+ 【怎么用】
6
+ 这个文件可以直接双击打开,先看效果。
7
+ 想放进自己的项目:把整个文件交给 AI(Claude / ChatGPT / Cursor 等),再说一句你的技术栈和想要的调整,
8
+ 例如“把这个效果改写成 React 组件,放在首屏当背景,颜色换成我的品牌色”。下面的说明就是写给它看的。
9
+
10
+ 【效果】
11
+ 一页一屏,像放幻灯片。往下翻时,当前页上的信息先被往下扯长,再整体拽走并淡出,下一页稍后从上方浮现;往上翻时,上一页从底部被拉上来并轻轻回弹。也可以按住拖动:拖过两成屏高或甩得够快就翻页,否则弹回。背景不参与。
12
+
13
+ 【实现原理】
14
+ - 把要被扯走的那一页 cloneNode 一份,横切成 N 条(N ≈ 屏高 / 24)。每条是一个 overflow:hidden 的窄条,里面放整页副本并向上偏移。
15
+ - 每条的竖向位移 D(y) = p × (g + (1 - g) × e^(-(gy - y) / L)):p 是拉动距离,gy 是抓点,g 是“整体跟上”的比例。抓点上方的部分按距离指数衰减地落后,所以被拉长;抓点下方整体跟着走。
16
+ - 每条用 translateY(D(y0)) scaleY((h + D(y1) - D(y0)) / h),相邻条的接口永远对得上。
17
+ - 自动翻页分两段:先扯 14% 屏高(g = 0,只拉长),再 ease-in 拽走,同时 g 从 0 到 1。
18
+ - 拖动时用指针速度判断“甩”:松手速度大于 0.9px/ms 也算完成。
19
+
20
+ 【可以调的参数】
21
+ - 切条高度约 24px
22
+ - 先扯的幅度 14% 屏高
23
+ - 衰减长度 L = 0.35 × 屏高
24
+ - 完成阈值 22% 屏高或 0.9px/ms
25
+ - 回弹用 back 缓动
26
+
27
+ 【注意】
28
+ - 只做竖向拉伸,不要横向缩放:同一个字跨好几条,每条横向比例不同,边缘会错成台阶。
29
+ - 切条按整数像素对齐、互不重叠。快照是透明的,重叠会让接缝处的文字被画两遍,出现一道深线。
30
+ - 快照只复制页面信息,不要把背景一起切进去,否则和底下实时运行的背景对不上。
31
+ - 不适合整站都这样翻页(太慢)。更适合作品放映、幻灯片这类场景。
32
+ - 颜色都来自 CSS 变量(paper / ink / dim / rule / accent),换成项目自己的颜色即可。
33
+ - 要处理 prefers-reduced-motion:系统要求减少动态效果时只显示静止画面。
34
+ - 组件卸载时要停掉动画循环、移除事件监听(mount 返回的 destroy)。
35
+
36
+ 【opts 参数】(mount 的第二个参数,不传就用默认值;文件末尾的 mount 调用里是你在 Motion Hub 上调好的值)
37
+ - slides:每页内容(一行一页:标题 | 说明),默认 "背景 | 等高线 · 点阵 · 风场 · 网格\n过渡 | 11 种深浅切换\n翻页 | 拉扯翻页\n交互 | 片头 · 开关 · 卡片 · 光标"
38
+
39
+ 【代码结构】
40
+ - <style>:配色变量和这个效果用到的样式
41
+ - 第一段 <script>:MH 核心(注册表、噪声、缓动;canvasHost 负责画布尺寸、指针坐标、动画循环和暂停)
42
+ - 第二段 <script>:效果本身,核心是 MH.register 里的 mount(el, opts),返回 { pause, resume, destroy }
43
+ - 最后一段 <script>:把效果挂到 #fx 上并启动
44
+ -->
45
+ <html lang="zh-CN">
46
+ <head>
47
+ <meta charset="utf-8">
48
+ <meta name="viewport" content="width=device-width,initial-scale=1">
49
+ <title>拉扯翻页</title>
50
+ <style>
51
+ /* Motion Hub 效果样式:配色变量(Acid:浅色暖灰 + 紫罗兰,深色近黑 + 酸橙绿)+ 各效果用到的样式 + 整页过渡遮罩。按“@css 名称”标记分段,复制代码时只带用到的段。 */
52
+ :root{
53
+ --paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E;
54
+ --serif:"Songti SC","STSong","Noto Serif SC",serif;
55
+ --sans:"PingFang SC","Helvetica Neue","Noto Sans SC",sans-serif;
56
+ --latin:"Iowan Old Style",Palatino,Georgia,serif;
57
+ --mono:"SF Mono",Menlo,monospace;
58
+ }
59
+ [data-theme="night"]{--paper:#111113;--paper2:#1B1B1F;--ink:#F2F1F5;--body:#C4C2CC;--dim:#7E7B88;--rule:#2A2A30;--accent:#C6F21E;--on-accent:#111113;--hi:#8E75FF}
60
+ [data-theme="day"]{--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E}
61
+
62
+ /* 拉扯翻页 */
63
+ .pd{position:absolute;inset:0;overflow:hidden;user-select:none;-webkit-user-select:none;cursor:grab}
64
+ .pd:active{cursor:grabbing}
65
+ .pd-slide{position:absolute;inset:0;display:none;align-items:center;gap:6cqw;padding:0 8cqw}
66
+ .pd-slide.on{display:flex}
67
+ .pd-n{font:italic 400 30cqh/.8 var(--latin);color:transparent;-webkit-text-stroke:1.5px var(--accent)}
68
+ .pd-slide h3{font:900 9cqh/1.1 var(--serif)}
69
+ .pd-slide p{margin-top:2cqh;font:400 3.4cqh/1.6 var(--serif);color:var(--body)}
70
+ .pd-pn{position:absolute;right:20px;bottom:16px;font:500 12px/1 var(--mono);color:var(--accent)}
71
+ .pd-sheet{position:absolute;inset:0;pointer-events:none;z-index:5}
72
+ .pd-band{position:absolute;left:0;width:100%;overflow:hidden;transform-origin:0 0;will-change:transform}
73
+ .pd-band>.pd-slide{display:flex}
74
+ html,body{height:100%;margin:0;background:var(--paper)}
75
+ #fx{position:relative;height:100vh;overflow:hidden;container-type:size}
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div id="fx"></div>
80
+ <script>
81
+ // Motion Hub 核心:注册表 + 公共工具。所有效果文件都是普通 <script>(不是 ES 模块),
82
+ // 这样直接双击 index.html(file://)也能运行。每个效果调用 MH.register({...}) 把自己登记进来。
83
+ //
84
+ // 效果对象的约定:
85
+ // id 唯一标识(小写短横线)
86
+ // name 中文名
87
+ // cat 分类:背景 / 过渡 / 翻页 / 片头 / 交互
88
+ // tech 用到的技术(展示用)
89
+ // desc 一句话说明
90
+ // mount(el, opts) → { pause(), resume(), destroy() }
91
+ // el 是一个已经有尺寸的容器;opts.expanded 为 true 表示全屏体验(关掉幽灵光标、允许滚轮等)
92
+ // params 可调参数(可选),实验台据此生成参数面板,每项 { k, label, type, def, ... }:
93
+ // type 'range':数值滑块,带 min / max / step,可选 unit;'text':单行文字;'lines':多行文字,一行一项
94
+ // mount 拿到的 opts 已经按 def 补齐(register 里包了一层),效果代码直接读 opts.k 即可
95
+ window.MH = (() => {
96
+ const effects = [];
97
+ // 记下效果来自哪个文件(复制代码时只带这个文件)
98
+ const register = e => {
99
+ const src = document.currentScript && document.currentScript.src; e.file = e.file || (src ? src.split('/').pop() : '');
100
+ if (e.params) { const m = e.mount; e.mount = (el, opts = {}) => m.call(e, el, { ...defaults(e), ...opts }); }
101
+ effects.push(e);
102
+ };
103
+ const defaults = e => Object.fromEntries((e.params || []).map(p => [p.k, p.def]));
104
+ // 'lines' 参数:数组原样返回;字符串按行拆开,去掉空行
105
+ const lines = v => Array.isArray(v) ? v : String(v || '').split('\n').map(x => x.trim()).filter(Boolean);
106
+ const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
107
+ const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
108
+ const lerp = (a, b, k) => a + (b - a) * k;
109
+ const ease = {
110
+ in: t => t * t * t,
111
+ out: t => 1 - (1 - t) ** 3,
112
+ inOut: t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
113
+ back: t => { const c = 1.4; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2; },
114
+ };
115
+ const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
116
+
117
+ // 二维 Perlin 噪声,seed 决定排列表
118
+ function perlin(seed = 1) {
119
+ const P = new Uint8Array(512), p = [...Array(256).keys()];
120
+ let s = seed;
121
+ for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [p[i], p[j]] = [p[j], p[i]]; }
122
+ for (let i = 0; i < 512; i++) P[i] = p[i & 255];
123
+ const fade = t => t * t * t * (t * (t * 6 - 15) + 10), grad = (h, x, y) => ((h & 1) ? -x : x) + ((h & 2) ? -y : y);
124
+ return (x, y) => {
125
+ const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
126
+ const u = fade(x), v = fade(y), a = P[X] + Y, b = P[X + 1] + Y;
127
+ const l1 = grad(P[a], x, y) + u * (grad(P[b], x - 1, y) - grad(P[a], x, y));
128
+ const l2 = grad(P[a + 1], x, y - 1) + u * (grad(P[b + 1], x - 1, y - 1) - grad(P[a + 1], x, y - 1));
129
+ return l1 + v * (l2 - l1);
130
+ };
131
+ }
132
+
133
+ // 画布按设备像素比放大,画的时候仍用 CSS 像素坐标
134
+ function fit(cv, w, h) {
135
+ const dpr = Math.min(devicePixelRatio || 1, 2);
136
+ cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
137
+ const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;
138
+ }
139
+ // 从容器读主题色(--paper / --ink / --dim / --rule / --accent)
140
+ function colors(el) {
141
+ const cs = getComputedStyle(el), v = n => cs.getPropertyValue(n).trim();
142
+ const t = el.closest('[data-theme]');
143
+ return { paper: v('--paper'), ink: v('--ink'), dim: v('--dim'), rule: v('--rule'), accent: v('--accent'), night: !!t && t.dataset.theme === 'night' };
144
+ }
145
+ // 一段时长 ms 的补间,fn(k) 中 k 从 0 到 1;减少动态效果时直接到终点
146
+ const tween = (ms, fn) => new Promise(res => {
147
+ if (still()) { fn(1); return res(); }
148
+ const t0 = performance.now();
149
+ const f = now => { const k = clamp((now - t0) / ms); fn(k); k < 1 ? requestAnimationFrame(f) : res(); };
150
+ requestAnimationFrame(f);
151
+ });
152
+ // 尺寸缩放系数:全屏约 1,缩略图约 0.45,让小画面像“整页缩小”而不是挤满细节
153
+ const scaleOf = W => clamp(W / 1200, .45, 1);
154
+
155
+ // 幽灵光标:没人操作时,沿一条利萨如曲线在容器里慢慢移动,让缩略图自己演示交互
156
+ const ghostAt = (now, W, H, seed = 0) => {
157
+ const t = now / 1000 + seed * 7;
158
+ return { x: W * (.5 + .32 * Math.sin(t * .55) * Math.cos(t * .21)), y: H * (.5 + .3 * Math.sin(t * .83 + 1.3)) };
159
+ };
160
+
161
+ // 效果宿主:(可选)建画布、跟随尺寸、换算局部指针坐标、管动画循环和暂停。
162
+ // setup(h) 返回 { resize(W,H), frame(now), down(x,y), theme(), ghostClick },h 提供 ctx / W / H / P / s / colors()
163
+ // ghostClick: false 表示幽灵光标不替它“点击”(例如片头,点一下会重播)
164
+ function canvasHost(el, opts, setup, { canvas = true } = {}) {
165
+ const cv = canvas ? document.createElement('canvas') : null;
166
+ if (cv) { cv.className = 'mh-canvas'; el.append(cv); }
167
+ let ctx, W = 0, H = 0, raf = 0, running = false, lastReal = -1e9, lastGhostClick = 0;
168
+ const P = { x: -9999, y: -9999, real: false };
169
+ const seed = hash(effects.length + el.childElementCount);
170
+ const h = { cv, get ctx() { return ctx; }, get W() { return W; }, get H() { return H; }, P, s: 1, colors: () => colors(el), expanded: !!opts.expanded };
171
+ const api = setup(h);
172
+ const size = () => {
173
+ const r = el.getBoundingClientRect(); if (!r.width || !r.height) return;
174
+ W = r.width; H = r.height; h.s = scaleOf(W); if (cv) ctx = fit(cv, W, H); api.resize && api.resize(W, H);
175
+ if (!running) api.frame(performance.now());
176
+ };
177
+ const ro = new ResizeObserver(size); ro.observe(el);
178
+ const local = e => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
179
+ const onMove = e => { [P.x, P.y] = local(e); P.real = true; lastReal = performance.now(); };
180
+ const onLeave = () => { P.real = false; P.x = P.y = -9999; };
181
+ const onDown = e => { if (e.target.closest('a, button')) return; lastReal = performance.now(); const [x, y] = local(e); api.down && api.down(x, y); };
182
+ el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);
183
+ const mo = new MutationObserver(() => { api.theme && api.theme(); if (!running) api.frame(performance.now()); });
184
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-palette'] });
185
+ const tick = now => {
186
+ if (!running) return;
187
+ // 缩略图里 2.5 秒没人碰,就让幽灵光标接管;偶尔“点”一下展示点击效果
188
+ if (!h.expanded && now - lastReal > 2500) {
189
+ const g = ghostAt(now, W, H, seed); P.x = g.x; P.y = g.y;
190
+ if (api.down && api.ghostClick !== false && now - lastGhostClick > 4200) { lastGhostClick = now; api.down(g.x, g.y); }
191
+ }
192
+ if (W && H) api.frame(now);
193
+ raf = requestAnimationFrame(tick);
194
+ };
195
+ size();
196
+ return {
197
+ pause() { running = false; cancelAnimationFrame(raf); },
198
+ resume() { if (running) return; if (still()) { api.frame(performance.now()); return; } running = true; raf = requestAnimationFrame(tick); },
199
+ destroy() { this.pause(); ro.disconnect(); mo.disconnect(); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); cv && cv.remove(); },
200
+ };
201
+ }
202
+
203
+ // 让 CSS 能对自定义属性做补间(遮罩半径等)
204
+ if (window.CSS && CSS.registerProperty) {
205
+ for (const name of ['--vr', '--vw']) { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch (e) { /* 已注册 */ } }
206
+ }
207
+
208
+ return { effects, register, defaults, lines, still, clamp, lerp, ease, hash, perlin, fit, colors, tween, scaleOf, ghostAt, canvasHost };
209
+ })();
210
+ </script>
211
+ <script>
212
+ // 拉扯翻页:一页一屏,换页时只有页面上的信息被扯长、扯走,背景不动。
213
+ // 做法:把要被扯走的那一页复制一份,横切成很多细条;每条按离“抓点”的远近做竖向位移和拉伸,拼起来就是被拉长的样子。
214
+ // 只做竖向(横向不缩放),字的边缘才是连续的,没有台阶。
215
+ MH.register({
216
+ id: 'pull', name: '拉扯翻页', cat: '翻页', tech: 'DOM Slicing · Exponential Decay · Pointer Drag',
217
+ desc: '往下翻,当前页的信息先被扯长,再整体拽走;往上翻,上一页从底部被拉上来。也可以按住拖动,拖过两成或甩得够快就翻页,否则弹回。',
218
+ usage: `const deck = MH.effects.find(e => e.id === 'pull').mount(el, { slides: ['…', '…'] });\ndeck.resume();`,
219
+ params: [
220
+ { k: 'slides', label: '每页内容(一行一页:标题 | 说明)', type: 'lines', def: '背景 | 等高线 · 点阵 · 风场 · 网格\n过渡 | 11 种深浅切换\n翻页 | 拉扯翻页\n交互 | 片头 · 开关 · 卡片 · 光标' },
221
+ ],
222
+ mount(el, opts) {
223
+ // slides 可以是 [[编号, 标题, 说明], …],也可以是“标题 | 说明”一行一页的文字
224
+ const PAGES = MH.lines(opts.slides).map((l, i) => Array.isArray(l) ? l : [String(i + 1).padStart(2, '0'), ...l.split('|').map(x => x.trim())].concat('').slice(0, 3));
225
+ if (!PAGES.length) PAGES.push(['01', '', '']);
226
+ el.insertAdjacentHTML('beforeend', `<div class="pd">${PAGES.map(([n, t, d]) => `<section class="pd-slide"><div class="pd-n">${n}</div><div><h3>${t}</h3><p>${d}</p></div></section>`).join('')}<div class="pd-pn"></div></div>`);
227
+ const box = el.lastElementChild, slides = [...box.querySelectorAll('.pd-slide')], pn = box.querySelector('.pd-pn');
228
+ const { clamp, lerp, ease, tween } = MH;
229
+ let cur = 0, busy = false, dirStep = 1, running = false, timer = 0, lastReal = -1e9;
230
+ slides[0].classList.add('on');
231
+ const paint = () => { pn.textContent = `${String(cur + 1).padStart(2, '0')} / ${String(slides.length).padStart(2, '0')}`; };
232
+ paint();
233
+
234
+ // 快照切条:整数像素对齐,静止时不重叠也没有缝
235
+ function sheetOf(node) {
236
+ const W = box.clientWidth, H = box.clientHeight, N = Math.max(16, Math.round(H / 24));
237
+ const sheet = document.createElement('div'); sheet.className = 'pd-sheet';
238
+ const strips = [];
239
+ for (let i = 0; i < N; i++) {
240
+ const y0 = Math.floor(i * H / N), y1 = Math.floor((i + 1) * H / N);
241
+ const s = document.createElement('div'); s.className = 'pd-band'; s.style.top = y0 + 'px'; s.style.height = y1 - y0 + 'px';
242
+ const c = node.cloneNode(true); c.classList.add('on'); c.style.cssText = `top:${-y0}px;height:${H}px;opacity:1;transform:none`;
243
+ s.append(c); sheet.append(s); strips.push({ el: s, y0, y1 });
244
+ }
245
+ box.append(sheet);
246
+ return { sheet, strips, H };
247
+ }
248
+ // dir=down:抓点 gy 上方按距离指数衰减地跟随(被拉长),下方整体跟走;dir=up:上沿领先、下面落后。g 是整体跟上的比例
249
+ function place(S, { dir, p, g, L, gy }) {
250
+ const H = S.H;
251
+ const D = dir === 'down' ? y => p * (g + (1 - g) * (y >= gy ? 1 : Math.exp(-(gy - y) / L))) : y => H - p * (g + (1 - g) * Math.exp(-y / L));
252
+ for (const s of S.strips) { const a = D(s.y0), b = D(s.y1), h = s.y1 - s.y0; s.el.style.transform = `translate3d(0,${a}px,0) scaleY(${Math.max(.2, (h + b - a) / h)})`; }
253
+ }
254
+ const enter = (e, k, from) => { e.style.opacity = k; e.style.transform = `translateY(${(1 - k) * from}px)`; };
255
+ const reset = e => { e.style.opacity = ''; e.style.transform = ''; };
256
+
257
+ async function go(to) {
258
+ if (busy || to === cur) return;
259
+ const H = box.clientHeight, off = 28 * MH.scaleOf(box.clientWidth);
260
+ if (to < 0 || to >= slides.length) { // 到头:拽动一点又弹回
261
+ busy = true; const e = slides[cur], d = to > cur ? 1 : -1;
262
+ await tween(160, k => e.style.transform = `translateY(${d * 34 * ease.out(k)}px) scaleY(${1 + .03 * ease.out(k)})`);
263
+ await tween(380, k => e.style.transform = `translateY(${d * 34 * (1 - ease.back(k))}px)`);
264
+ reset(e); busy = false; return;
265
+ }
266
+ busy = true;
267
+ const from = slides[cur], next = slides[to];
268
+ if (to > cur) { // 下一页:当前页的信息先被往下扯长,再整体拽走、淡出;下一页稍后从上方浮现
269
+ const S = sheetOf(from), L = H * .35;
270
+ from.classList.remove('on'); next.classList.add('on'); enter(next, 0, -off); cur = to; paint();
271
+ await tween(320, k => place(S, { dir: 'down', p: H * .14 * ease.out(k), g: 0, L, gy: H }));
272
+ await tween(440, k => { place(S, { dir: 'down', p: lerp(H * .14, H * 1.1, ease.in(k)), g: ease.inOut(k), L, gy: H }); S.sheet.style.opacity = 1 - clamp((k - .1) / .6); enter(next, ease.out(clamp((k - .45) / .55)), -off); });
273
+ reset(next); S.sheet.remove();
274
+ } else { // 上一页:从底部探出头,被扯着上沿拉到顶,轻轻回弹;当前页往上淡出
275
+ const S = sheetOf(next), L = H * .4;
276
+ place(S, { dir: 'up', p: 0, g: 0, L, gy: 0 }); S.sheet.style.opacity = 0;
277
+ await tween(180, k => { place(S, { dir: 'up', p: 46 * ease.out(k), g: 0, L, gy: 0 }); S.sheet.style.opacity = k; });
278
+ await tween(300, k => { place(S, { dir: 'up', p: lerp(46, H * .32, ease.out(k)), g: 0, L, gy: 0 }); from.style.opacity = 1 - .6 * k; from.style.transform = `translateY(${-24 * k}px)`; });
279
+ await tween(460, k => { place(S, { dir: 'up', p: lerp(H * .32, H, ease.back(k)), g: ease.out(k), L, gy: 0 }); from.style.opacity = .4 * (1 - ease.out(k)); });
280
+ from.classList.remove('on'); reset(from); next.classList.add('on'); cur = to; paint(); S.sheet.remove();
281
+ }
282
+ busy = false;
283
+ }
284
+
285
+ // 输入:舞台上滚轮、方向键;按住拖动(指针按住的地方就是抓点)
286
+ let acc = 0, coolUntil = 0, drag = null;
287
+ const onWheel = e => {
288
+ e.preventDefault(); lastReal = performance.now();
289
+ if (busy || performance.now() < coolUntil) return;
290
+ acc += e.deltaY;
291
+ if (Math.abs(acc) > 40) { const d = acc > 0 ? 1 : -1; acc = 0; coolUntil = performance.now() + 900; go(cur + d); }
292
+ };
293
+ const onKey = e => {
294
+ if (!box.isConnected || !running) return;
295
+ if (['ArrowDown', 'PageDown'].includes(e.key)) { e.preventDefault(); lastReal = performance.now(); go(cur + 1); }
296
+ if (['ArrowUp', 'PageUp'].includes(e.key)) { e.preventDefault(); lastReal = performance.now(); go(cur - 1); }
297
+ };
298
+ const pose = (dir, dy, H) => dir === 'down'
299
+ ? (p => ({ p, g: clamp(p / (H * .9)) ** 2, q: clamp(p / (H * .5)) }))(Math.max(0, dy))
300
+ : (p => ({ p, g: clamp(p / (H * .9)) ** 2, q: clamp(p / (H * .6)) }))(clamp(-dy * 1.15, 0, H));
301
+ const onDown = e => {
302
+ if (busy || e.button > 0 || e.target.closest('a, button')) return;
303
+ const r = box.getBoundingClientRect(); lastReal = performance.now();
304
+ drag = { y: e.clientY, gy: e.clientY - r.top, v: 0, ly: e.clientY, lt: performance.now(), S: null, dir: null };
305
+ box.setPointerCapture(e.pointerId);
306
+ };
307
+ const onMove = e => {
308
+ if (!drag) return;
309
+ const dy = e.clientY - drag.y, now = performance.now(), H = box.clientHeight, off = 28 * MH.scaleOf(box.clientWidth);
310
+ drag.v = (e.clientY - drag.ly) / Math.max(1, now - drag.lt); drag.ly = e.clientY; drag.lt = now; lastReal = now;
311
+ if (!drag.dir) {
312
+ if (Math.abs(dy) < 8) return;
313
+ drag.dir = dy > 0 ? 'down' : 'up';
314
+ const to = cur + (drag.dir === 'down' ? 1 : -1);
315
+ if (to < 0 || to >= slides.length) { drag.edge = true; return; }
316
+ busy = true; drag.to = to;
317
+ if (drag.dir === 'down') { drag.S = sheetOf(slides[cur]); slides[cur].classList.remove('on'); slides[to].classList.add('on'); enter(slides[to], 0, -off); }
318
+ else drag.S = sheetOf(slides[to]);
319
+ }
320
+ if (drag.edge) { const d = dy * .18; slides[cur].style.transform = `translateY(${d}px) scaleY(${1 + Math.abs(d) / H * .6})`; return; }
321
+ const { p, g, q } = pose(drag.dir, dy, H);
322
+ if (drag.dir === 'down') { place(drag.S, { dir: 'down', p, g, L: H * .3, gy: drag.gy }); drag.S.sheet.style.opacity = 1 - .7 * q; enter(slides[drag.to], .8 * q, -off); }
323
+ else { place(drag.S, { dir: 'up', p, g, L: H * .4, gy: 0 }); drag.S.sheet.style.opacity = clamp(p / (H * .15)); slides[cur].style.opacity = 1 - .8 * q; slides[cur].style.transform = `translateY(${-30 * q}px)`; }
324
+ };
325
+ const onUp = async e => {
326
+ const d = drag; drag = null;
327
+ if (!d || !d.dir) return;
328
+ const H = box.clientHeight, off = 28 * MH.scaleOf(box.clientWidth);
329
+ if (d.edge) { const e2 = slides[cur], m = /translateY\(([-\d.]+)px\)/.exec(e2.style.transform), y0 = m ? +m[1] : 0; await tween(320, k => e2.style.transform = `translateY(${y0 * (1 - ease.back(k))}px)`); reset(e2); return; }
330
+ const dy = e.clientY - d.y, { p: p0, g: g0, q: q0 } = pose(d.dir, dy, H);
331
+ const done = d.dir === 'down' ? (dy > H * .22 || d.v > .9) : (-dy > H * .22 || d.v < -.9);
332
+ const from = slides[cur], next = slides[d.to];
333
+ if (d.dir === 'down') {
334
+ if (done) { await tween(360, k => { place(d.S, { dir: 'down', p: lerp(p0, H * 1.1, ease.in(k)), g: lerp(g0, 1, ease.out(k)), L: H * .3, gy: d.gy }); d.S.sheet.style.opacity = (1 - .7 * q0) * (1 - k); enter(next, lerp(.8 * q0, 1, ease.out(k)), -off); }); reset(next); cur = d.to; paint(); }
335
+ else { await tween(420, k => { place(d.S, { dir: 'down', p: p0 * (1 - ease.back(k)), g: g0 * (1 - k), L: H * .3, gy: d.gy }); d.S.sheet.style.opacity = lerp(1 - .7 * q0, 1, k); enter(next, .8 * q0 * (1 - k), -off); }); next.classList.remove('on'); reset(next); from.classList.add('on'); }
336
+ } else {
337
+ if (done) { await tween(420, k => { place(d.S, { dir: 'up', p: lerp(p0, H, ease.back(k)), g: lerp(g0, 1, ease.out(k)), L: H * .4, gy: 0 }); d.S.sheet.style.opacity = 1; from.style.opacity = (1 - .8 * q0) * (1 - k); }); from.classList.remove('on'); reset(from); next.classList.add('on'); cur = d.to; paint(); }
338
+ else { await tween(360, k => { place(d.S, { dir: 'up', p: p0 * (1 - ease.out(k)), g: g0 * (1 - k), L: H * .4, gy: 0 }); enter(from, lerp(1 - .8 * q0, 1, k), 0); from.style.transform = `translateY(${-30 * q0 * (1 - k)}px)`; }); reset(from); }
339
+ }
340
+ d.S.sheet.remove(); busy = false;
341
+ };
342
+ box.addEventListener('wheel', onWheel, { passive: false });
343
+ box.addEventListener('pointerdown', onDown); box.addEventListener('pointermove', onMove); box.addEventListener('pointerup', onUp); box.addEventListener('pointercancel', onUp);
344
+ addEventListener('keydown', onKey);
345
+ // 没人操作时自动来回翻:到最后一页就往回翻
346
+ const auto = () => {
347
+ if (!running) return;
348
+ if (performance.now() - lastReal > 3500 && !busy) {
349
+ if (cur + dirStep < 0 || cur + dirStep >= slides.length) dirStep = -dirStep;
350
+ go(cur + dirStep);
351
+ }
352
+ timer = setTimeout(auto, 2400);
353
+ };
354
+ return {
355
+ pause() { running = false; clearTimeout(timer); },
356
+ resume() { if (running) return; running = true; if (!MH.still()) timer = setTimeout(auto, 1600); },
357
+ destroy() { this.pause(); removeEventListener('keydown', onKey); box.remove(); },
358
+ };
359
+ },
360
+ });
361
+ </script>
362
+ <script>
363
+ MH.effects.find(e => e.id === 'pull').mount(document.getElementById('fx'), {
364
+ "slides": [
365
+ "背景 | 等高线 · 点阵 · 风场 · 网格",
366
+ "过渡 | 11 种深浅切换",
367
+ "翻页 | 拉扯翻页",
368
+ "交互 | 片头 · 开关 · 卡片 · 光标"
369
+ ]
370
+ }).resume();
371
+ </script>
372
+ </body>
373
+ </html>