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,381 @@
1
+ <!doctype html>
2
+ <!--
3
+ 「斜切扫过」 · Motion Hub · MIT + Commons Clause(可免费用于任何项目,不能售卖效果本身)
4
+
5
+ 【怎么用】
6
+ 这个文件可以直接双击打开,先看效果。
7
+ 想放进自己的项目:把整个文件交给 AI(Claude / ChatGPT / Cursor 等),再说一句你的技术栈和想要的调整,
8
+ 例如“把这个效果改写成 React 组件,放在首屏当背景,颜色换成我的品牌色”。下面的说明就是写给它看的。
9
+
10
+ 【效果】
11
+ 深浅模式切换时,新的颜色按特定形状盖住旧的。这一种:斜边多边形 clip-path,从点击所在的一侧扫向另一侧。
12
+
13
+ 【实现原理】
14
+ - 舞台演示:两层小页面(浅色、深色)叠在一起,新的一层在上面按形状揭开。
15
+ - clip-path 型(圆形、墨水、斜切、光圈、液面)直接用 Web Animations 补间 clip-path 关键帧。
16
+ - 遮罩型(点阵扩散、百叶窗、网点溶解、柔光圆)把半径或宽度写成用 CSS.registerProperty 注册的 --vr / --vw,再补间这个变量。
17
+ - 整页使用:MH.bindThemeToggle(按钮, 切换主题的函数, "slice"),内部是 document.startViewTransition(切换主题),再对 ::view-transition-new(root) 做同样的动画。
18
+ - 过渡播完之前再点不生效,等这一次播完才能再切:连点时打断正在播的过渡会显得生硬。
19
+ - 形状:斜边多边形 clip-path,从点击所在的一侧扫向另一侧。
20
+
21
+ 【可以调的参数】
22
+ - 缓动 cubic-bezier(.6,0,.2,1)
23
+ - 触发点 = 点击位置(键盘触发时用按钮中心)
24
+
25
+ 【注意】
26
+ - View Transitions 支持 Chrome / Edge 111+、Safari 18+;不支持或开启“减少动态效果”时直接切换。
27
+ - 旧快照和新快照都要 animation: none,只让新快照按形状出现。
28
+ - 如果背景有 canvas 动画,点切换按钮时不要再触发背景的点击效果,否则会被快照冻住。
29
+ - 颜色都来自 CSS 变量(paper / ink / dim / rule / accent),换成项目自己的颜色即可。
30
+ - 要处理 prefers-reduced-motion:系统要求减少动态效果时只显示静止画面。
31
+ - 组件卸载时要停掉动画循环、移除事件监听(mount 返回的 destroy)。
32
+
33
+ 【opts 参数】(mount 的第二个参数,不传就用默认值;文件末尾的 mount 调用里是你在 Motion Hub 上调好的值)
34
+ - dur:时长,默认 750(200–2400ms)
35
+
36
+ 【代码结构】
37
+ - <style>:配色变量和这个效果用到的样式
38
+ - 第一段 <script>:MH 核心(注册表、噪声、缓动;canvasHost 负责画布尺寸、指针坐标、动画循环和暂停)
39
+ - 第二段 <script>:效果本身,核心是 MH.register 里的 mount(el, opts),返回 { pause, resume, destroy }
40
+ - 最后一段 <script>:把效果挂到 #fx 上并启动
41
+ -->
42
+ <html lang="zh-CN">
43
+ <head>
44
+ <meta charset="utf-8">
45
+ <meta name="viewport" content="width=device-width,initial-scale=1">
46
+ <title>斜切扫过</title>
47
+ <style>
48
+ /* Motion Hub 效果样式:配色变量(Acid:浅色暖灰 + 紫罗兰,深色近黑 + 酸橙绿)+ 各效果用到的样式 + 整页过渡遮罩。按“@css 名称”标记分段,复制代码时只带用到的段。 */
49
+ :root{
50
+ --paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E;
51
+ --serif:"Songti SC","STSong","Noto Serif SC",serif;
52
+ --sans:"PingFang SC","Helvetica Neue","Noto Sans SC",sans-serif;
53
+ --latin:"Iowan Old Style",Palatino,Georgia,serif;
54
+ --mono:"SF Mono",Menlo,monospace;
55
+ }
56
+ [data-theme="night"]{--paper:#111113;--paper2:#1B1B1F;--ink:#F2F1F5;--body:#C4C2CC;--dim:#7E7B88;--rule:#2A2A30;--accent:#C6F21E;--on-accent:#111113;--hi:#8E75FF}
57
+ [data-theme="day"]{--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E}
58
+
59
+ /* 深浅过渡的小页面 */
60
+ .tt{position:absolute;inset:0;overflow:hidden;perspective:1400px;background:#0b0b0c;cursor:pointer}
61
+ .tt-scene{position:absolute;inset:0;background:var(--paper) radial-gradient(circle,color-mix(in srgb,var(--dim) 45%,transparent) 1.1px,transparent 1.6px) 0 0/22px 22px;color:var(--ink);backface-visibility:hidden}
62
+ .tt-mock{position:absolute;left:8%;top:50%;transform:translateY(-50%);width:60%}
63
+ .tt-mock .tt-tag{font:500 2.2cqw/1 var(--sans);letter-spacing:.3em;color:var(--accent)}
64
+ .tt-mock b{display:block;margin-top:1.4cqw;font:900 15cqw/1 var(--serif)}
65
+ .tt-mock i{display:block;height:1.1cqw;margin-top:2.2cqw;background:var(--rule);width:80%}
66
+ .tt-mock i.short{width:46%;background:var(--accent)}
67
+ .tt-scene[data-fx="dots"]{
68
+ -webkit-mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--band)),transparent calc(var(--vr) - var(--band) + 1px)),radial-gradient(circle,#000 calc(var(--tile) * .25),transparent calc(var(--tile) * .28)),radial-gradient(circle at var(--vx) var(--vy),transparent calc(var(--vr) - var(--band) - 10px),#000 calc(var(--vr) - var(--band)),#000 var(--vr),transparent var(--vr));
69
+ -webkit-mask-size:100% 100%,var(--tile) var(--tile),100% 100%;-webkit-mask-composite:source-over,source-in;
70
+ mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--band)),transparent calc(var(--vr) - var(--band) + 1px)),radial-gradient(circle,#000 calc(var(--tile) * .25),transparent calc(var(--tile) * .28)),radial-gradient(circle at var(--vx) var(--vy),transparent calc(var(--vr) - var(--band) - 10px),#000 calc(var(--vr) - var(--band)),#000 var(--vr),transparent var(--vr));
71
+ mask-size:100% 100%,var(--tile) var(--tile),100% 100%;mask-composite:add,intersect}
72
+ .tt-scene[data-fx="blinds"]{-webkit-mask-image:repeating-linear-gradient(90deg,#000 0 var(--vw),transparent var(--vw) var(--period));mask-image:repeating-linear-gradient(90deg,#000 0 var(--vw),transparent var(--vw) var(--period))}
73
+ .tt-scene[data-fx="halftone"]{-webkit-mask-image:radial-gradient(circle,#000 var(--vr),transparent calc(var(--vr) + .5px));-webkit-mask-size:var(--tile) var(--tile);mask-image:radial-gradient(circle,#000 var(--vr),transparent calc(var(--vr) + .5px));mask-size:var(--tile) var(--tile)}
74
+ .tt-scene[data-fx="soft"]{-webkit-mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--soft)),transparent var(--vr));mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--soft)),transparent var(--vr))}
75
+
76
+ /* 整页深浅切换(MH.themeSwitch,View Transitions) */
77
+ [data-vfx]:not([data-vfx="fade"])::view-transition-old(root),[data-vfx]:not([data-vfx="fade"])::view-transition-new(root){animation:none;mix-blend-mode:normal}
78
+ [data-vfx="dots"]::view-transition-new(root){
79
+ -webkit-mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--band)),transparent calc(var(--vr) - var(--band) + 1px)),radial-gradient(circle,#000 calc(var(--tile) * .25),transparent calc(var(--tile) * .28)),radial-gradient(circle at var(--vx) var(--vy),transparent calc(var(--vr) - var(--band) - 10px),#000 calc(var(--vr) - var(--band)),#000 var(--vr),transparent var(--vr));
80
+ -webkit-mask-size:100% 100%,var(--tile) var(--tile),100% 100%;-webkit-mask-composite:source-over,source-in;
81
+ mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--band)),transparent calc(var(--vr) - var(--band) + 1px)),radial-gradient(circle,#000 calc(var(--tile) * .25),transparent calc(var(--tile) * .28)),radial-gradient(circle at var(--vx) var(--vy),transparent calc(var(--vr) - var(--band) - 10px),#000 calc(var(--vr) - var(--band)),#000 var(--vr),transparent var(--vr));
82
+ mask-size:100% 100%,var(--tile) var(--tile),100% 100%;mask-composite:add,intersect}
83
+ [data-vfx="blinds"]::view-transition-new(root){-webkit-mask-image:repeating-linear-gradient(90deg,#000 0 var(--vw),transparent var(--vw) var(--period));mask-image:repeating-linear-gradient(90deg,#000 0 var(--vw),transparent var(--vw) var(--period))}
84
+ [data-vfx="halftone"]::view-transition-new(root){-webkit-mask-image:radial-gradient(circle,#000 var(--vr),transparent calc(var(--vr) + .5px));-webkit-mask-size:var(--tile) var(--tile);mask-image:radial-gradient(circle,#000 var(--vr),transparent calc(var(--vr) + .5px));mask-size:var(--tile) var(--tile)}
85
+ [data-vfx="soft"]::view-transition-new(root){-webkit-mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--soft)),transparent var(--vr));mask-image:radial-gradient(circle at var(--vx) var(--vy),#000 calc(var(--vr) - var(--soft)),transparent var(--vr))}
86
+ [data-vfx="flip"]::view-transition-group(root){background:#0b0b0c}
87
+ [data-vfx="flip"]::view-transition-old(root),[data-vfx="flip"]::view-transition-new(root){backface-visibility:hidden}
88
+ html,body{height:100%;margin:0;background:var(--paper)}
89
+ #fx{position:relative;height:100vh;overflow:hidden;container-type:size}
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <div id="fx"></div>
94
+ <script>
95
+ // Motion Hub 核心:注册表 + 公共工具。所有效果文件都是普通 <script>(不是 ES 模块),
96
+ // 这样直接双击 index.html(file://)也能运行。每个效果调用 MH.register({...}) 把自己登记进来。
97
+ //
98
+ // 效果对象的约定:
99
+ // id 唯一标识(小写短横线)
100
+ // name 中文名
101
+ // cat 分类:背景 / 过渡 / 翻页 / 片头 / 交互
102
+ // tech 用到的技术(展示用)
103
+ // desc 一句话说明
104
+ // mount(el, opts) → { pause(), resume(), destroy() }
105
+ // el 是一个已经有尺寸的容器;opts.expanded 为 true 表示全屏体验(关掉幽灵光标、允许滚轮等)
106
+ // params 可调参数(可选),实验台据此生成参数面板,每项 { k, label, type, def, ... }:
107
+ // type 'range':数值滑块,带 min / max / step,可选 unit;'text':单行文字;'lines':多行文字,一行一项
108
+ // mount 拿到的 opts 已经按 def 补齐(register 里包了一层),效果代码直接读 opts.k 即可
109
+ window.MH = (() => {
110
+ const effects = [];
111
+ // 记下效果来自哪个文件(复制代码时只带这个文件)
112
+ const register = e => {
113
+ const src = document.currentScript && document.currentScript.src; e.file = e.file || (src ? src.split('/').pop() : '');
114
+ if (e.params) { const m = e.mount; e.mount = (el, opts = {}) => m.call(e, el, { ...defaults(e), ...opts }); }
115
+ effects.push(e);
116
+ };
117
+ const defaults = e => Object.fromEntries((e.params || []).map(p => [p.k, p.def]));
118
+ // 'lines' 参数:数组原样返回;字符串按行拆开,去掉空行
119
+ const lines = v => Array.isArray(v) ? v : String(v || '').split('\n').map(x => x.trim()).filter(Boolean);
120
+ const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
121
+ const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
122
+ const lerp = (a, b, k) => a + (b - a) * k;
123
+ const ease = {
124
+ in: t => t * t * t,
125
+ out: t => 1 - (1 - t) ** 3,
126
+ inOut: t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
127
+ back: t => { const c = 1.4; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2; },
128
+ };
129
+ const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
130
+
131
+ // 二维 Perlin 噪声,seed 决定排列表
132
+ function perlin(seed = 1) {
133
+ const P = new Uint8Array(512), p = [...Array(256).keys()];
134
+ let s = seed;
135
+ for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [p[i], p[j]] = [p[j], p[i]]; }
136
+ for (let i = 0; i < 512; i++) P[i] = p[i & 255];
137
+ const fade = t => t * t * t * (t * (t * 6 - 15) + 10), grad = (h, x, y) => ((h & 1) ? -x : x) + ((h & 2) ? -y : y);
138
+ return (x, y) => {
139
+ const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
140
+ const u = fade(x), v = fade(y), a = P[X] + Y, b = P[X + 1] + Y;
141
+ const l1 = grad(P[a], x, y) + u * (grad(P[b], x - 1, y) - grad(P[a], x, y));
142
+ 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));
143
+ return l1 + v * (l2 - l1);
144
+ };
145
+ }
146
+
147
+ // 画布按设备像素比放大,画的时候仍用 CSS 像素坐标
148
+ function fit(cv, w, h) {
149
+ const dpr = Math.min(devicePixelRatio || 1, 2);
150
+ cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
151
+ const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;
152
+ }
153
+ // 从容器读主题色(--paper / --ink / --dim / --rule / --accent)
154
+ function colors(el) {
155
+ const cs = getComputedStyle(el), v = n => cs.getPropertyValue(n).trim();
156
+ const t = el.closest('[data-theme]');
157
+ return { paper: v('--paper'), ink: v('--ink'), dim: v('--dim'), rule: v('--rule'), accent: v('--accent'), night: !!t && t.dataset.theme === 'night' };
158
+ }
159
+ // 一段时长 ms 的补间,fn(k) 中 k 从 0 到 1;减少动态效果时直接到终点
160
+ const tween = (ms, fn) => new Promise(res => {
161
+ if (still()) { fn(1); return res(); }
162
+ const t0 = performance.now();
163
+ const f = now => { const k = clamp((now - t0) / ms); fn(k); k < 1 ? requestAnimationFrame(f) : res(); };
164
+ requestAnimationFrame(f);
165
+ });
166
+ // 尺寸缩放系数:全屏约 1,缩略图约 0.45,让小画面像“整页缩小”而不是挤满细节
167
+ const scaleOf = W => clamp(W / 1200, .45, 1);
168
+
169
+ // 幽灵光标:没人操作时,沿一条利萨如曲线在容器里慢慢移动,让缩略图自己演示交互
170
+ const ghostAt = (now, W, H, seed = 0) => {
171
+ const t = now / 1000 + seed * 7;
172
+ return { x: W * (.5 + .32 * Math.sin(t * .55) * Math.cos(t * .21)), y: H * (.5 + .3 * Math.sin(t * .83 + 1.3)) };
173
+ };
174
+
175
+ // 效果宿主:(可选)建画布、跟随尺寸、换算局部指针坐标、管动画循环和暂停。
176
+ // setup(h) 返回 { resize(W,H), frame(now), down(x,y), theme(), ghostClick },h 提供 ctx / W / H / P / s / colors()
177
+ // ghostClick: false 表示幽灵光标不替它“点击”(例如片头,点一下会重播)
178
+ function canvasHost(el, opts, setup, { canvas = true } = {}) {
179
+ const cv = canvas ? document.createElement('canvas') : null;
180
+ if (cv) { cv.className = 'mh-canvas'; el.append(cv); }
181
+ let ctx, W = 0, H = 0, raf = 0, running = false, lastReal = -1e9, lastGhostClick = 0;
182
+ const P = { x: -9999, y: -9999, real: false };
183
+ const seed = hash(effects.length + el.childElementCount);
184
+ const h = { cv, get ctx() { return ctx; }, get W() { return W; }, get H() { return H; }, P, s: 1, colors: () => colors(el), expanded: !!opts.expanded };
185
+ const api = setup(h);
186
+ const size = () => {
187
+ const r = el.getBoundingClientRect(); if (!r.width || !r.height) return;
188
+ W = r.width; H = r.height; h.s = scaleOf(W); if (cv) ctx = fit(cv, W, H); api.resize && api.resize(W, H);
189
+ if (!running) api.frame(performance.now());
190
+ };
191
+ const ro = new ResizeObserver(size); ro.observe(el);
192
+ const local = e => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
193
+ const onMove = e => { [P.x, P.y] = local(e); P.real = true; lastReal = performance.now(); };
194
+ const onLeave = () => { P.real = false; P.x = P.y = -9999; };
195
+ const onDown = e => { if (e.target.closest('a, button')) return; lastReal = performance.now(); const [x, y] = local(e); api.down && api.down(x, y); };
196
+ el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);
197
+ const mo = new MutationObserver(() => { api.theme && api.theme(); if (!running) api.frame(performance.now()); });
198
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-palette'] });
199
+ const tick = now => {
200
+ if (!running) return;
201
+ // 缩略图里 2.5 秒没人碰,就让幽灵光标接管;偶尔“点”一下展示点击效果
202
+ if (!h.expanded && now - lastReal > 2500) {
203
+ const g = ghostAt(now, W, H, seed); P.x = g.x; P.y = g.y;
204
+ if (api.down && api.ghostClick !== false && now - lastGhostClick > 4200) { lastGhostClick = now; api.down(g.x, g.y); }
205
+ }
206
+ if (W && H) api.frame(now);
207
+ raf = requestAnimationFrame(tick);
208
+ };
209
+ size();
210
+ return {
211
+ pause() { running = false; cancelAnimationFrame(raf); },
212
+ resume() { if (running) return; if (still()) { api.frame(performance.now()); return; } running = true; raf = requestAnimationFrame(tick); },
213
+ destroy() { this.pause(); ro.disconnect(); mo.disconnect(); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); cv && cv.remove(); },
214
+ };
215
+ }
216
+
217
+ // 让 CSS 能对自定义属性做补间(遮罩半径等)
218
+ if (window.CSS && CSS.registerProperty) {
219
+ for (const name of ['--vr', '--vw']) { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch (e) { /* 已注册 */ } }
220
+ }
221
+
222
+ return { effects, register, defaults, lines, still, clamp, lerp, ease, hash, perlin, fit, colors, tween, scaleOf, ghostAt, canvasHost };
223
+ })();
224
+ </script>
225
+ <script>
226
+ // 深浅切换过渡。两种用法:
227
+ // 1. 缩略图 / 全屏体验:容器里叠两层“小页面”(浅色、深色),新的一层按选定形状盖上去。不依赖 View Transitions。
228
+ // 2. 真实网站:MH.themeSwitch(event, apply, fx) 用 View Transitions 给整页做同样的过渡(见本文件末尾)。
229
+ // 形状函数 shape(x, y, R, W, H, s) 返回 WAAPI 关键帧;x/y 是触发点,R 是触发点到最远角的距离,s 是缩放系数。
230
+ (() => {
231
+ const FX = {
232
+ circle: { name: '圆形揭开', dur: 650, desc: '从点击处放大一个圆,盖住整页。',
233
+ shape: (x, y, R) => [{ clipPath: `circle(0px at ${x}px ${y}px)` }, { clipPath: `circle(${R}px at ${x}px ${y}px)` }] },
234
+ ink: { name: '墨水晕开', dur: 900, desc: '从点击处开始,边缘是圆润的不规则形,先快后慢,像墨滴洇开。',
235
+ shape: (x, y, R) => {
236
+ const N = 96, ph = [0, 0, 0].map(() => Math.random() * 6.28);
237
+ const wob = Array.from({ length: N }, (_, i) => { const a = i / N * Math.PI * 2; return .88 + .07 * Math.sin(3 * a + ph[0]) + .05 * Math.sin(5 * a + ph[1]) + .03 * Math.sin(8 * a + ph[2]); });
238
+ const ring = k => `polygon(${wob.map((w, i) => { const a = i / N * Math.PI * 2, r = R * k * (k < 1 ? w : 1.3); return `${(x + Math.cos(a) * r).toFixed(1)}px ${(y + Math.sin(a) * r).toFixed(1)}px`; }).join(',')})`;
239
+ return [{ clipPath: ring(0) }, { clipPath: ring(.45), offset: .4 }, { clipPath: ring(1.25) }];
240
+ } },
241
+ dots: { name: '点阵扩散', dur: 1000, desc: '前沿是一圈网点,后面才是实心,适合配点阵背景。',
242
+ shape: (x, y, R, W, H, s) => [{ '--vr': '0px' }, { '--vr': `${R + 180 * s}px` }] },
243
+ blinds: { name: '百叶窗', dur: 700, desc: '一条条竖帘同时翻开,利落、有节奏。',
244
+ shape: (x, y, R, W, H, s) => [{ '--vw': '0px' }, { '--vw': `${72 * s}px` }] },
245
+ slice: { name: '斜切扫过', dur: 750, desc: '一道斜边从点击的那一侧扫向另一侧。',
246
+ shape: (x, y, R, W) => {
247
+ const L = x < W / 2, P = k => L ? `polygon(0 0, ${k}% 0, ${k - 35}% 100%, 0 100%)` : `polygon(100% 0, ${100 - k}% 0, ${135 - k}% 100%, 100% 100%)`;
248
+ return [{ clipPath: P(0) }, { clipPath: P(140) }];
249
+ } },
250
+ iris: { name: '光圈快门', dur: 800, desc: '六边形从点击处旋转着张开,像相机光圈。',
251
+ shape: (x, y, R) => Array.from({ length: 25 }, (_, f) => {
252
+ const k = f / 24, r = R * 1.25 * (k * k * (3 - 2 * k)), rot = k * 2.2;
253
+ return { clipPath: `polygon(${Array.from({ length: 6 }, (_, i) => { const a = rot + i * Math.PI / 3; return `${(x + Math.cos(a) * r).toFixed(1)}px ${(y + Math.sin(a) * r).toFixed(1)}px`; }).join(',')})` };
254
+ }) },
255
+ wave: { name: '液面上涨', dur: 1100, desc: '新颜色像水一样从底部涨上来,水面起伏。',
256
+ shape: (x, y, R, W, H, s) => Array.from({ length: 31 }, (_, f) => {
257
+ const k = f / 30, level = H * (1.08 - k * 1.25), amp = 46 * s * Math.sin(Math.PI * k), N = 32;
258
+ const top = Array.from({ length: N + 1 }, (_, i) => `${(W * i / N).toFixed(1)}px ${(level + amp * Math.sin(i / N * Math.PI * 3 + k * 9)).toFixed(1)}px`);
259
+ return { clipPath: `polygon(0 ${H}px, ${top.join(',')}, ${W}px ${H}px)` };
260
+ }) },
261
+ halftone: { name: '网点溶解', dur: 750, desc: '整屏同时长出印刷网点,越长越大,直到连成一片。',
262
+ shape: (x, y, R, W, H, s) => [{ '--vr': '0px' }, { '--vr': `${14 * s}px` }] },
263
+ soft: { name: '柔光圆', dur: 800, desc: '和圆形揭开一样从点击处扩散,但边缘是一圈柔和的渐隐。',
264
+ shape: (x, y, R, W, H, s) => [{ '--vr': '0px' }, { '--vr': `${R + 260 * s}px` }] },
265
+ flip: { name: '翻页', dur: 900, desc: '整页像卡片一样绕竖轴翻过去,背面就是另一种颜色。', shape: null },
266
+ fade: { name: '淡入淡出', dur: 400, desc: '最普通的渐变,作对照。', shape: () => [{ opacity: 0 }, { opacity: 1 }] },
267
+ };
268
+ const EASE = 'cubic-bezier(.6,0,.2,1)';
269
+ // 遮罩里用到的尺寸(随缩放系数变):点阵前沿宽度、网点格子、柔光边宽、百叶窗周期
270
+ const sizeVars = (el, s) => {
271
+ el.style.setProperty('--band', `${160 * s}px`); el.style.setProperty('--tile', `${18 * s}px`);
272
+ el.style.setProperty('--soft', `${260 * s}px`); el.style.setProperty('--period', `${72 * s}px`);
273
+ };
274
+
275
+ // 小页面:同一份内容,浅色一层、深色一层
276
+ const scene = theme => `<div class="tt-scene" data-theme="${theme}"><div class="tt-mock">
277
+ <span class="tt-tag">${theme === 'night' ? '深色' : '浅色'}</span><b>Aa</b><i></i><i></i><i class="short"></i></div></div>`;
278
+
279
+ function mountTransition(id) {
280
+ const fx = FX[id];
281
+ return (el, opts) => {
282
+ el.insertAdjacentHTML('beforeend', `<div class="tt">${scene('day')}${scene('night')}</div>`);
283
+ const box = el.lastElementChild, [day, night] = box.children;
284
+ let cur = 'day', running = false, timer = 0, lastReal = -1e9, settle = null;
285
+ night.style.visibility = 'hidden';
286
+ // 过渡播完之前再点不生效,等这一次播完才能再切(连点时被打断很生硬)
287
+ const play = (x, y) => {
288
+ if (settle) return;
289
+ const W = box.clientWidth, H = box.clientHeight, s = MH.scaleOf(W), R = Math.hypot(Math.max(x, W - x), Math.max(y, H - y));
290
+ const top = cur === 'day' ? night : day, bottom = cur === 'day' ? day : night;
291
+ cur = cur === 'day' ? 'night' : 'day'; // 目标状态立刻生效
292
+ sizeVars(top, s); top.style.setProperty('--vx', x + 'px'); top.style.setProperty('--vy', y + 'px');
293
+ top.dataset.fx = id; top.style.zIndex = 2; bottom.style.zIndex = 1; top.style.visibility = 'visible';
294
+ const anims = []; let done = false;
295
+ settle = () => {
296
+ if (done) return; done = true; settle = null;
297
+ anims.forEach(a => a.cancel());
298
+ delete top.dataset.fx; top.style.visibility = 'visible'; bottom.style.visibility = 'hidden';
299
+ top.style.zIndex = 1; bottom.style.zIndex = 0;
300
+ };
301
+ if (MH.still()) return settle();
302
+ const quiet = () => {}; // 被打断时动画会被 cancel,finished 会 reject,忽略即可
303
+ if (id === 'flip') {
304
+ const o = { duration: opts.dur / 2, easing: 'cubic-bezier(.5,0,.5,1)', fill: 'both' };
305
+ top.style.visibility = 'hidden';
306
+ const a1 = bottom.animate([{ transform: 'rotateY(0)' }, { transform: 'rotateY(90deg)' }], o); anims.push(a1);
307
+ a1.finished.then(() => {
308
+ if (done) return;
309
+ top.style.visibility = 'visible'; bottom.style.visibility = 'hidden';
310
+ const a2 = top.animate([{ transform: 'rotateY(-90deg)' }, { transform: 'rotateY(0)' }], o); anims.push(a2);
311
+ a2.finished.then(settle, quiet);
312
+ }, quiet);
313
+ } else {
314
+ const a = top.animate(fx.shape(x, y, R, W, H, s), { duration: opts.dur, easing: EASE, fill: 'forwards' }); anims.push(a);
315
+ a.finished.then(settle, quiet);
316
+ }
317
+ };
318
+ const onDown = e => { lastReal = performance.now(); const r = box.getBoundingClientRect(); play(e.clientX - r.left, e.clientY - r.top); };
319
+ box.addEventListener('pointerdown', onDown);
320
+ // 缩略图里没人点的时候,每隔一会儿在“幽灵光标”的位置自己触发一次
321
+ const auto = () => {
322
+ if (!running) return;
323
+ if (!opts.expanded && performance.now() - lastReal > 3000) { const g = MH.ghostAt(performance.now(), box.clientWidth, box.clientHeight, MH.hash(id.length)); play(g.x, g.y); }
324
+ timer = setTimeout(auto, 2600 + opts.dur);
325
+ };
326
+ return {
327
+ pause() { running = false; clearTimeout(timer); },
328
+ resume() { if (running || MH.still()) return; running = true; timer = setTimeout(auto, 900); },
329
+ destroy() { this.pause(); box.removeEventListener('pointerdown', onDown); box.remove(); },
330
+ };
331
+ };
332
+ }
333
+ for (const [id, fx] of Object.entries(FX)) {
334
+ MH.register({ id: `theme-${id}`, name: fx.name, cat: '过渡', tech: 'View Transitions API · ' + (id === 'flip' ? 'WAAPI · 3D Transform' : (/dots|blinds|halftone|soft/.test(id) ? 'CSS Mask · @property' : 'WAAPI · clip-path')), desc: fx.desc + ' 点画面任意位置触发。',
335
+ params: [{ k: 'dur', label: '时长', type: 'range', min: 200, max: 2400, step: 50, def: fx.dur, unit: 'ms' }], mount: mountTransition(id) });
336
+ }
337
+
338
+ // ───── 真实网站用:整页深浅切换 ─────
339
+ // apply() 负责真正改主题(例如切换 <html data-theme>);不支持 View Transitions 或减少动态效果时直接调用 apply()。
340
+ // 过渡播完之前再点不生效,等这一次播完才能再切。
341
+ let active = null;
342
+ // o.dur 可以改时长(毫秒),不传就用这个效果的默认时长
343
+ MH.themeSwitch = (e, apply, id = 'dots', o = {}) => {
344
+ const fx = FX[id] || FX.dots, root = document.documentElement, dur = o.dur || fx.dur;
345
+ if (!document.startViewTransition || MH.still()) return apply();
346
+ if (active) return;
347
+ const r = e.currentTarget && e.currentTarget.getBoundingClientRect ? e.currentTarget.getBoundingClientRect() : { left: innerWidth / 2, top: innerHeight / 2, width: 0, height: 0 };
348
+ const x = e.clientX || r.left + r.width / 2, y = e.clientY || r.top + r.height / 2;
349
+ const W = innerWidth, H = innerHeight, R = Math.hypot(Math.max(x, W - x), Math.max(y, H - y)), s = 1;
350
+ root.dataset.vfx = id; sizeVars(root, s);
351
+ root.style.setProperty('--vx', x + 'px'); root.style.setProperty('--vy', y + 'px');
352
+ const vt = document.startViewTransition(apply); active = vt;
353
+ vt.ready.then(() => {
354
+ if (id === 'flip') {
355
+ const o = { duration: dur / 2, easing: 'cubic-bezier(.5,0,.5,1)', fill: 'both' };
356
+ root.animate([{ transform: 'perspective(1800px) rotateY(0)' }, { transform: 'perspective(1800px) rotateY(90deg)' }], { ...o, pseudoElement: '::view-transition-old(root)' });
357
+ root.animate([{ transform: 'perspective(1800px) rotateY(-90deg)' }, { transform: 'perspective(1800px) rotateY(0)' }], { ...o, delay: dur / 2, pseudoElement: '::view-transition-new(root)' });
358
+ } else if (fx.shape && id !== 'fade') {
359
+ // fill: 'forwards':停在最后一帧,否则过渡层撤掉前会有一帧遮罩归零,整屏闪回旧颜色
360
+ root.animate(fx.shape(x, y, R, W, H, s), { duration: dur, easing: EASE, fill: 'forwards', pseudoElement: '::view-transition-new(root)' });
361
+ }
362
+ }).catch(() => {});
363
+ vt.finished.finally(() => { if (active === vt) { active = null; delete root.dataset.vfx; } });
364
+ };
365
+ // 绑定深浅开关(过渡播完之前再点不生效)
366
+ MH.bindThemeToggle = (btn, apply, id = 'dots', o = {}) => btn.addEventListener('click', e => MH.themeSwitch(e, apply, id, o));
367
+ MH.themeFx = Object.keys(FX);
368
+ })();
369
+ </script>
370
+ <script>
371
+ // 舞台演示:点画面任意位置切换
372
+ MH.effects.find(e => e.id === 'theme-slice').mount(document.getElementById('fx'), {
373
+ "dur": 750
374
+ }).resume();
375
+ // 整页深浅切换(View Transitions,过渡播完前再点不生效):
376
+ // MH.bindThemeToggle(button, () => {
377
+ // const r = document.documentElement; r.dataset.theme = r.dataset.theme === 'night' ? 'day' : 'night';
378
+ // }, 'slice', { dur: 750 });
379
+ </script>
380
+ </body>
381
+ </html>