cursor.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. /**
  2. * Cursor — 产品UI演示光标组件包
  3. *
  4. * 配合 browser_window.jsx / macos_window.jsx 使用,配方与参数出处见
  5. * references/ui-demo-animation.md 八式④(轨迹算法:animation-best-practices §3.5;
  6. * ripple 参数:shotcraft·type-and-filter + 解耦配方;seek 安全规则:gsap-recipes §6)。
  7. *
  8. * 帧确定性:全文件禁 Math.random / Date.now,随机感一律 mulberry32 种子推导。
  9. * 同一帧无论 seek 多少次,画面完全一致。
  10. *
  11. * ── 用法A · Stage 时钟(animations.jsx)─────────────────────────
  12. *
  13. * const { Stage, Sprite } = window.Animations;
  14. * const { CursorSprite, ClickRipple, HoverHighlight } = window;
  15. *
  16. * <Stage duration={8}>
  17. * <Sprite start={1} end={2.2}> {/* 光标弧线移到按钮,末段收敛手抖 *\/}
  18. * <CursorSprite points={[[220, 480], [860, 300]]} seed={7} clickAt={0.96} />
  19. * </Sprite>
  20. * <Sprite start={2.1} end={3.0}> {/* 点击涟漪:双圈解耦 *\/}
  21. * <ClickRipple x={860} y={300} color="#D97757" duration={0.9} />
  22. * </Sprite>
  23. * </Stage>
  24. *
  25. * hover 联动高亮(时间驱动命中,非事件驱动):
  26. * const sampler = window.CursorKit.buildCursorSampler(points, { seed: 7 });
  27. * const hovered = window.CursorKit.hoverIndexAt(sampler, easedU, [
  28. * { id: 'save', rect: { x: 820, y: 270, w: 96, h: 44 } },
  29. * ]);
  30. * <HoverHighlight rect={{...}} intensity={hovered === 'save' ? 1 : 0} />
  31. *
  32. * 拖拽:光标传 dragRange={[0.2, 0.8]}(区间内切抓取手型+微缩),
  33. * 被拖元素用同一 sampler 采样减去抓取点偏移驱动,光标和元素永远同步。
  34. *
  35. * ── 用法B · GSAP timeline(HyperFrames 渲染)───────────────────
  36. *
  37. * const K = window.CursorKit;
  38. * const sampler = K.buildCursorSampler([[220, 480], [860, 300]], { seed: 7 });
  39. * K.attachCursorTween(tl, '#cursor', sampler, { duration: 1.1, position: 's1+=0.5' });
  40. * K.attachClickTween(tl, '#cursor', { position: '>' });
  41. * K.attachRippleTween(tl, '#rip1', '#rip2', { position: '<' });
  42. * // 别忘了 gsap-recipes §6.3 的首帧保险:注册 timeline 后手动补一次初始 set
  43. *
  44. * 光标形状:arrow(macOS 箭头,默认)/ hand(可点手型)/ grab(拖拽中)/ text(I-beam)
  45. */
  46. /* ══════════════ 工具层(纯函数,两种驱动共用)══════════════ */
  47. function mulberry32(seed) {
  48. return function () {
  49. seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
  50. let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  51. t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  52. return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  53. };
  54. }
  55. const CursorEasing = {
  56. outCubic: (t) => 1 - Math.pow(1 - t, 3),
  57. inOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
  58. inQuad: (t) => t * t,
  59. };
  60. // Catmull-Rom 单段插值(p1→p2,p0/p3 是相邻控制点)
  61. function catmullRom(p0, p1, p2, p3, t) {
  62. const t2 = t * t, t3 = t2 * t;
  63. return [
  64. 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t +
  65. (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
  66. (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
  67. 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t +
  68. (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
  69. (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
  70. ];
  71. }
  72. /**
  73. * buildCursorSampler(points, opts) → sample(u) → {x, y}
  74. *
  75. * - points 只有 2 个时自动插一个偏离中点的控制点做弧线
  76. * (真人鼠标不走直线,best-practices §3.5),偏移方向由 seed 决定
  77. * - ≥3 个点走 Catmull-Rom 平滑(huarec 光标平滑同款插值)
  78. * - 手抖:两条不可通约频率正弦叠加,幅度 ±wobble px,
  79. * 随 u→1 收敛到 0(接近目标时人手会稳)
  80. */
  81. function buildCursorSampler(points, opts) {
  82. const o = Object.assign({ seed: 7, wobble: 2, arc: 0.18 }, opts);
  83. const rand = mulberry32(o.seed);
  84. const ph1 = rand() * 6.283, ph2 = rand() * 6.283;
  85. const side = rand() < 0.5 ? -1 : 1;
  86. let pts = points.map((p) => [p[0], p[1]]);
  87. if (pts.length === 2) {
  88. const [a, b] = pts;
  89. const dx = b[0] - a[0], dy = b[1] - a[1];
  90. const mid = [a[0] + dx * 0.5 - dy * o.arc * side, a[1] + dy * 0.5 + dx * o.arc * side];
  91. pts = [a, mid, b];
  92. }
  93. // 首尾补虚拟点,让 Catmull-Rom 覆盖全程
  94. const ext = [pts[0], ...pts, pts[pts.length - 1]];
  95. const segs = pts.length - 1;
  96. return function sample(u) {
  97. const uu = Math.max(0, Math.min(1, u));
  98. const f = uu * segs;
  99. const i = Math.min(segs - 1, Math.floor(f));
  100. const lt = f - i;
  101. const [x0, y0] = catmullRom(ext[i], ext[i + 1], ext[i + 2], ext[i + 3], lt);
  102. const damp = o.wobble * (1 - uu); // 接近目标收敛
  103. return {
  104. x: x0 + Math.sin(uu * 47.13 + ph1) * damp, // 47.13 / 33.7 不可通约
  105. y: y0 + Math.sin(uu * 33.7 + ph2) * damp,
  106. };
  107. };
  108. }
  109. // hover 命中:时间驱动的确定性 hit test(不是事件监听)
  110. function hoverIndexAt(sampler, u, targets, pad) {
  111. const p = sampler(u);
  112. const m = pad || 0;
  113. for (const t of targets) {
  114. const r = t.rect;
  115. if (p.x >= r.x - m && p.x <= r.x + r.w + m && p.y >= r.y - m && p.y <= r.y + r.h + m) return t.id;
  116. }
  117. return null;
  118. }
  119. /**
  120. * rippleRingState(tSec, opts) → { scale, opacity }
  121. * 双圈 ripple 的单圈状态。扩散与消散解耦(shotcraft 实测配方):
  122. * 扩散 out-cubic EXPAND 帧(冲),消散线性 FADE 帧(匀),FADE > EXPAND。
  123. * 默认 22f/26f@30fps;紧凑场景(type-and-filter)可压到各 10f。
  124. */
  125. function rippleRingState(tSec, opts) {
  126. const o = Object.assign({ delayF: 0, expandF: 22, fadeF: 26, r0: 14, r1: 54, fps: 30 }, opts);
  127. const t = tSec - o.delayF / o.fps;
  128. if (t < 0) return { scale: o.r0 / o.r1, opacity: 0 };
  129. const pe = Math.min(1, t / (o.expandF / o.fps));
  130. const pf = Math.min(1, t / (o.fadeF / o.fps));
  131. return {
  132. scale: (o.r0 + (o.r1 - o.r0) * CursorEasing.outCubic(pe)) / o.r1,
  133. opacity: 1 - pf,
  134. };
  135. }
  136. /* ══════════════ 光标形状(SVG,黑体白描边,paintOrder 保准确轮廓)══════════════ */
  137. const CURSOR_PATHS = {
  138. // macOS 箭头:左缘垂直、斜边到右翼、带点击尾。热点在 (0,0)
  139. arrow: {
  140. viewBox: '0 0 17 22',
  141. d: 'M1.5 1.5 L1.5 18.6 L6.4 13.9 L9.1 20.3 L11.9 19.1 L9.2 12.8 L14.5 12.8 Z',
  142. hotspot: [1.5, 1.5],
  143. },
  144. // 可点手型(简化食指手)。热点在指尖
  145. hand: {
  146. viewBox: '0 0 22 24',
  147. d: 'M9.2 1.9 c1 0 1.5 .7 1.5 1.6 v6.1 l1 .1 v-4.4 c0-1.9 2.8-1.9 2.8 0 v4.7 l.9 .1 v-3.2 c0-1.8 2.6-1.8 2.6 0 v3.6 l.9 .2 v-1.6 c0-1.6 2.3-1.6 2.3 0 v5.6 c0 4.3-2.9 7.3-7.3 7.3 h-2.1 c-2.9 0-4.5-1.3-5.9-3.7 L3.1 13.4 c-.7-1.2 .8-2.4 1.9-1.5 l2.7 2.3 V3.5 c0-.9 .6-1.6 1.5-1.6 Z',
  148. hotspot: [9.9, 1.9],
  149. },
  150. // 拖拽中(握拳):hand 的收指变体
  151. grab: {
  152. viewBox: '0 0 22 22',
  153. d: 'M5.4 7.2 c0-1.7 2.5-1.7 2.5 0 v2.1 l.9 0 v-3.3 c0-1.8 2.7-1.8 2.7 0 v3.3 l.9 0 v-2.9 c0-1.8 2.6-1.8 2.6 0 v3 l.9 .1 v-1.7 c0-1.6 2.3-1.6 2.3 0 v5.1 c0 4.2-2.8 7-7.1 7 h-1.9 c-2.8 0-4.4-1.2-5.7-3.6 L2.5 13.1 c-.6-1.2 .8-2.3 1.8-1.4 l1.1 .9 Z',
  154. hotspot: [10, 8],
  155. },
  156. // 文本 I-beam。热点在中心
  157. text: {
  158. viewBox: '0 0 10 22',
  159. d: 'M1 1.5 h3 v0 c.4 0 .7 .2 1 .5 c.3-.3 .6-.5 1-.5 h3 v2 h-2.6 c-.2 0-.4 .2-.4 .4 v14.2 c0 .2 .2 .4 .4 .4 H9 v2 H6 c-.4 0-.7-.2-1-.5 c-.3 .3-.6 .5-1 .5 H1 v-2 h2.6 c.2 0 .4-.2 .4-.4 V3.9 c0-.2-.2-.4-.4-.4 H1 Z',
  160. hotspot: [5, 11],
  161. },
  162. };
  163. function CursorIcon({ variant = 'arrow', size = 22 }) {
  164. const s = CURSOR_PATHS[variant] || CURSOR_PATHS.arrow;
  165. return (
  166. <svg width={size} height={size * 1.25} viewBox={s.viewBox}
  167. style={{ display: 'block', overflow: 'visible' }}>
  168. <path d={s.d} fill="#111" stroke="#fff" strokeWidth="1.4"
  169. strokeLinejoin="round" style={{ paintOrder: 'stroke' }} />
  170. </svg>
  171. );
  172. }
  173. /* ══════════════ Stage 时钟组件(配合 animations.jsx)══════════════ */
  174. /**
  175. * CursorSprite — 放在 <Sprite> 内,沿路径移动的光标
  176. *
  177. * props:
  178. * points [[x,y],...] 路径点(舞台坐标)。2 个点自动成弧
  179. * seed 随机种子(换 seed = 换一版弧线和手抖)
  180. * wobble 手抖幅度 px(默认 2,best-practices §3.5 的 ±2px)
  181. * ease 进度缓动,默认 inOutQuad(起步加速+到达减速的对称人手感)
  182. * clickAt 0-1,此进度处做点击下压(scale 0.85 dip + 回弹,Anticipation)
  183. * dragRange [u0,u1],区间内切 grab 手型 + scale 0.94
  184. * variant 基础形状,默认 'arrow'
  185. * size 光标宽 px,默认 22
  186. */
  187. function CursorSprite({
  188. points, seed = 7, wobble = 2, ease = CursorEasing.inOutQuad,
  189. clickAt = null, dragRange = null, variant = 'arrow', size = 22, style,
  190. }) {
  191. const { useSprite } = window.Animations;
  192. const { t } = useSprite();
  193. const sampler = React.useMemo(
  194. () => buildCursorSampler(points, { seed, wobble }),
  195. [JSON.stringify(points), seed, wobble]
  196. );
  197. const u = ease(t);
  198. const p = sampler(u);
  199. let scale = 1;
  200. let shape = variant;
  201. if (dragRange && u >= dragRange[0] && u <= dragRange[1]) {
  202. shape = 'grab';
  203. scale = 0.94;
  204. }
  205. if (clickAt !== null) {
  206. const d = (u - clickAt) / 0.05; // 点击窗口 ±5% 进度
  207. if (d >= 0 && d < 1) scale *= 0.85 + 0.15 * CursorEasing.outCubic(d); // 回弹
  208. else if (d >= -0.6 && d < 0) scale *= 1 - 0.15 * CursorEasing.inQuad(1 + d / 0.6); // 下压
  209. }
  210. const hs = (CURSOR_PATHS[shape] || CURSOR_PATHS.arrow).hotspot;
  211. const k = size / 17; // 视觉尺寸归一
  212. return (
  213. <div style={{
  214. position: 'absolute', left: 0, top: 0, zIndex: 999, pointerEvents: 'none',
  215. transform: `translate(${p.x - hs[0] * k}px, ${p.y - hs[1] * k}px) scale(${scale})`,
  216. transformOrigin: `${hs[0] * k}px ${hs[1] * k}px`,
  217. filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35))',
  218. ...style,
  219. }}>
  220. <CursorIcon variant={shape} size={size} />
  221. </div>
  222. );
  223. }
  224. /**
  225. * ClickRipple — 双圈同心涟漪(放在独立 <Sprite> 里,从点击帧开始)
  226. * 双圈起点差 3f;半径 14→54 / 14→78;扩散 out-cubic 22f、消散线性 26f 解耦。
  227. * duration = 所在 Sprite 的时长(秒),用于把本地进度换算回秒。
  228. */
  229. function ClickRipple({ x, y, color = '#D97757', r1 = 54, r2 = 78, duration = 0.9, fps = 30 }) {
  230. const { useSprite } = window.Animations;
  231. const { t } = useSprite();
  232. const tSec = t * duration;
  233. const rings = [
  234. { rMax: r1, st: rippleRingState(tSec, { delayF: 0, r1, fps }) },
  235. { rMax: r2, st: rippleRingState(tSec, { delayF: 3, r1: r2, fps }) },
  236. ];
  237. return (
  238. <div style={{ position: 'absolute', left: x, top: y, zIndex: 998, pointerEvents: 'none' }}>
  239. {rings.map((r, i) => (
  240. <div key={i} style={{
  241. position: 'absolute',
  242. left: -r.rMax, top: -r.rMax, width: r.rMax * 2, height: r.rMax * 2,
  243. borderRadius: '50%',
  244. border: `3px solid ${color}`,
  245. boxShadow: `0 0 40px ${color}55`,
  246. transform: `scale(${r.st.scale})`, // 固定尺寸 + scale,不 tween 宽高
  247. opacity: r.st.opacity,
  248. }} />
  249. ))}
  250. </div>
  251. );
  252. }
  253. /**
  254. * HoverHighlight — 光标 hover 目标的联动高亮
  255. * intensity 0→1 由调用方从时间推导(配 hoverIndexAt),本组件只负责渲染:
  256. * hairline 描边浮现 + 轻微提亮,光标离开即撤。
  257. */
  258. function HoverHighlight({ rect, intensity = 0, color = '#D97757', radius = 8 }) {
  259. if (intensity <= 0) return null;
  260. return (
  261. <div style={{
  262. position: 'absolute', left: rect.x - 3, top: rect.y - 3,
  263. width: rect.w + 6, height: rect.h + 6,
  264. borderRadius: radius, pointerEvents: 'none',
  265. border: `1.5px solid ${color}`,
  266. boxShadow: `0 0 0 3px ${color}22`,
  267. opacity: intensity,
  268. backdropFilter: `brightness(${1 + 0.06 * intensity})`,
  269. }} />
  270. );
  271. }
  272. /* ══════════════ GSAP 驱动层(HyperFrames 渲染管线)══════════════ */
  273. /**
  274. * attachCursorTween — proxy tween 驱动光标 DOM 元素沿 sampler 路径移动
  275. * (gsap-recipes §3.5 的组件化封装;一切由 proxy.u 推导,seek-safe)
  276. */
  277. function attachCursorTween(tl, target, sampler, opts) {
  278. const o = Object.assign({ duration: 1.1, ease: 'power1.inOut', position: '>' }, opts);
  279. const proxy = { u: 0 };
  280. tl.to(proxy, {
  281. u: 1, duration: o.duration, ease: o.ease,
  282. onUpdate: () => {
  283. const p = sampler(proxy.u);
  284. gsap.set(target, { x: p.x, y: p.y });
  285. },
  286. }, o.position);
  287. return proxy;
  288. }
  289. /** attachClickTween — 点击 Anticipation:下压 0.85 再 back.out 回弹 */
  290. function attachClickTween(tl, target, opts) {
  291. const o = Object.assign({ position: '>' }, opts);
  292. tl.to(target, { scale: 0.85, duration: 0.08, ease: 'power1.in' }, o.position);
  293. tl.to(target, { scale: 1, duration: 0.25, ease: 'back.out' }, '>');
  294. }
  295. /**
  296. * attachRippleTween — 双圈 ripple。ring1/ring2 是两个固定尺寸的圆环元素
  297. * (直径 = 2×终态半径,初始 scale = r0/r1),只 tween scale 和 opacity。
  298. */
  299. function attachRippleTween(tl, ring1, ring2, opts) {
  300. const o = Object.assign({ r0: 14, r1: 54, r2: 78, fps: 30, position: '>' }, opts);
  301. const F = (n) => n / o.fps;
  302. [[ring1, o.r1, 0], [ring2, o.r2, 3]].forEach(([el, rMax, delayF]) => {
  303. const at = delayF === 0 ? o.position : '<+=' + F(delayF);
  304. tl.fromTo(el, { scale: o.r0 / rMax, autoAlpha: 1 },
  305. { scale: 1, duration: F(22), ease: 'power3.out' }, at); // 扩散:冲
  306. tl.to(el, { autoAlpha: 0, duration: F(26), ease: 'none' }, '<'); // 消散:匀,解耦
  307. });
  308. }
  309. /* ══════════════ 导出 ══════════════ */
  310. if (typeof window !== 'undefined') {
  311. window.CursorIcon = CursorIcon;
  312. window.CursorSprite = CursorSprite;
  313. window.ClickRipple = ClickRipple;
  314. window.HoverHighlight = HoverHighlight;
  315. window.CursorKit = {
  316. mulberry32,
  317. CursorEasing,
  318. buildCursorSampler,
  319. hoverIndexAt,
  320. rippleRingState,
  321. attachCursorTween,
  322. attachClickTween,
  323. attachRippleTween,
  324. CURSOR_PATHS,
  325. };
  326. }