我去,
这框真帅吧!
—— 希侑Kiyuu

前言

在B站首页刷到这个视频:“自瞄锁敌”?做一个带有磁力的网页光标 - 哔哩哔哩

这特效确实好看,评论区也有不少人放出了自己复刻版本。但UP主建议使用GSAP库重写,以获得最好的性能。

在评论区有人发了个链接:React Bits - Target Cursor,就是这个特效的GSAP复刻版,遂研究怎么移植。

GSAP

GSAP是啥?它的全称是 GreenSock Animation Platform,一个用来做网页动画的 JavaScript 动画库。官方把它描述为一个“健壮”的动画库,核心库负责高性能、跨浏览器的动画,额外能力则通过插件提供。它也是 framework-agnostic,也就是不绑定某个前端框架,能配合原生 JS、React、Vue、Webflow 等一起用。

这玩意可以简单理解为“比 CSS transition / keyframes 更强、更可控的动画工具”,不只可以动 DOM/CSS,还能动画 SVG、Canvas、WebGL、颜色、数字、字符串、运动路径。基本上,JavaScript 能改的东西,它都能做动画。

移植

官方也给出了代码示例,这里做一点魔改。

JavaScript

  • 添加了四角框的Padding大小修改功能
  • 修复了停滞一段时间后光标不转的问题
  • 添加可配置的开关
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
// target-cursor.js

(function () {
if (window.__targetCursorHexoBootstrapped) {
return;
}

window.__targetCursorHexoBootstrapped = true;
window.__targetCursorHexoLoaded = false;
// targetSelector: '#article-container img, #article-container a:not([data-fancybox]), #nav a, #aside-content a:not(.thumbnail):not(.title), #aside-content .aside-list-item, button, .cursor-target, .article-title'

var config = Object.assign(
{
targetSelector:
'#article-container img, #article-container a:not([data-fancybox]), #nav a, #aside-content a:not(.thumbnail):not(.title), #aside-content .aside-list-item, button, .cursor-target, .article-title',
spinDuration: 3,
hoverDuration: 0.3,
targetGap: 8,
hideDefaultCursor: false,
parallaxOn: true,
storageKey: 'target-cursor-enabled',
defaultEnabled: true
},
window.TargetCursorOptions || {}
);

function readCursorEnabledState() {
var fallback = config.defaultEnabled !== false;

try {
var storedValue = window.localStorage.getItem(config.storageKey);

if (storedValue === null) {
return fallback;
}

return storedValue !== '0' && storedValue !== 'false';
} catch (error) {
return fallback;
}
}

function writeCursorEnabledState(enabled) {
try {
window.localStorage.setItem(config.storageKey, enabled ? '1' : '0');
} catch (error) { }
}

function syncCursorEnabledState(enabled) {
document.documentElement.classList.toggle('target-cursor-enabled', enabled);
document.documentElement.classList.toggle('target-cursor-disabled', !enabled);
document.dispatchEvent(
new CustomEvent('target-cursor:change', {
detail: { enabled: enabled }
})
);
}

function isMobileDevice() {
var hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
var isSmallScreen = window.innerWidth <= 768;
var mobileRegex =
/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
var userAgent = (navigator.userAgent || navigator.vendor || window.opera || '').toLowerCase();

return (hasTouchScreen && isSmallScreen) || mobileRegex.test(userAgent);
}

function createCursor() {
var wrapper = document.createElement('div');
wrapper.className = 'target-cursor-wrapper';
wrapper.innerHTML =
'<div class="target-cursor-rotor">' +
'<div class="target-cursor-dot"></div>' +
'<div class="target-cursor-corner corner-tl"></div>' +
'<div class="target-cursor-corner corner-tr"></div>' +
'<div class="target-cursor-corner corner-br"></div>' +
'<div class="target-cursor-corner corner-bl"></div>' +
'</div>';

document.body.appendChild(wrapper);
return wrapper;
}

function setupTargetCursor() {
if (window.__targetCursorHexoLoaded || !readCursorEnabledState()) {
return;
}

if (isMobileDevice()) {
return;
}

if (!window.gsap) {
console.warn('[target-cursor] gsap is required.');
return;
}

window.__targetCursorHexoLoaded = true;

var gsap = window.gsap;
var cursor = document.querySelector('.target-cursor-wrapper') || createCursor();
var rotor = cursor.querySelector('.target-cursor-rotor');
var corners = Array.prototype.slice.call(
cursor.querySelectorAll('.target-cursor-corner')
);
var dot = cursor.querySelector('.target-cursor-dot');
var spinTl = null;
var activeTarget = null;
var leaveHandler = null;
var targetCornerPositions = null;
var activeStrength = { current: 0 };
var resumeTimeout = null;
var originalCursor = document.body.style.cursor;

var constants = {
cornerSize: 12
};

function cleanupActiveTarget() {
if (activeTarget && leaveHandler) {
activeTarget.removeEventListener('mouseleave', leaveHandler);
}

activeTarget = null;
leaveHandler = null;
}

function resetCorners() {
var size = constants.cornerSize;
var positions = [
{ x: -size * 1.5, y: -size * 1.5 },
{ x: size * 0.5, y: -size * 1.5 },
{ x: size * 0.5, y: size * 0.5 },
{ x: -size * 1.5, y: size * 0.5 }
];

corners.forEach(function (corner, index) {
gsap.to(corner, {
x: positions[index].x,
y: positions[index].y,
duration: 0.3,
ease: 'power3.out',
overwrite: 'auto'
});
});
}

function createSpinTimeline() {
if (spinTl) {
spinTl.kill();
}

spinTl = gsap
.timeline({ repeat: -1 })
.to(rotor, {
rotation: '+=360',
duration: config.spinDuration,
ease: 'none'
});
}

function clearHoverState() {
gsap.ticker.remove(tickerFn);
gsap.killTweensOf(activeStrength);
activeStrength.current = 0;
targetCornerPositions = null;
cleanupActiveTarget();
resetCorners();
}

function moveCursor(x, y) {
gsap.to(cursor, {
x: x,
y: y,
duration: 0.1,
ease: 'power3.out',
overwrite: 'auto'
});
}

function tickerFn() {
if (!targetCornerPositions) {
return;
}

var strength = activeStrength.current;
if (strength === 0) {
return;
}

var cursorX = Number(gsap.getProperty(cursor, 'x'));
var cursorY = Number(gsap.getProperty(cursor, 'y'));

corners.forEach(function (corner, index) {
var currentX = Number(gsap.getProperty(corner, 'x'));
var currentY = Number(gsap.getProperty(corner, 'y'));
var targetX = targetCornerPositions[index].x - cursorX;
var targetY = targetCornerPositions[index].y - cursorY;
var finalX = currentX + (targetX - currentX) * strength;
var finalY = currentY + (targetY - currentY) * strength;
var duration = strength >= 0.99 ? (config.parallaxOn ? 0.2 : 0) : 0.05;

gsap.to(corner, {
x: finalX,
y: finalY,
duration: duration,
ease: duration === 0 ? 'none' : 'power1.out',
overwrite: 'auto'
});
});
}

function enterTarget(target) {
if (!target || activeTarget === target) {
return;
}

cleanupActiveTarget();

if (resumeTimeout) {
clearTimeout(resumeTimeout);
resumeTimeout = null;
}

var rect = target.getBoundingClientRect();
var cornerSize = constants.cornerSize;
var targetGap = Number(config.targetGap) || 0;
var cursorX = Number(gsap.getProperty(cursor, 'x'));
var cursorY = Number(gsap.getProperty(cursor, 'y'));

targetCornerPositions = [
{ x: rect.left - targetGap, y: rect.top - targetGap },
{ x: rect.right + targetGap - cornerSize, y: rect.top - targetGap },
{
x: rect.right + targetGap - cornerSize,
y: rect.bottom + targetGap - cornerSize
},
{ x: rect.left - targetGap, y: rect.bottom + targetGap - cornerSize }
];

activeTarget = target;

gsap.killTweensOf(rotor, 'rotation');
gsap.killTweensOf(corners);
gsap.killTweensOf(activeStrength);

if (spinTl) {
spinTl.pause();
}

gsap.set(rotor, { rotation: 0 });
gsap.ticker.add(tickerFn);

gsap.to(activeStrength, {
current: 1,
duration: config.hoverDuration,
ease: 'power2.out',
overwrite: 'auto'
});

corners.forEach(function (corner, index) {
gsap.to(corner, {
x: targetCornerPositions[index].x - cursorX,
y: targetCornerPositions[index].y - cursorY,
duration: 0.2,
ease: 'power2.out',
overwrite: 'auto'
});
});

leaveHandler = function () {
clearHoverState();

resumeTimeout = window.setTimeout(function () {
if (!activeTarget) {
gsap.set(rotor, { rotation: 0 });
createSpinTimeline();
}

resumeTimeout = null;
}, 50);
};

target.addEventListener('mouseleave', leaveHandler);
}

function findTarget(startNode) {
var current = startNode;

while (current && current !== document.body) {
if (current.matches && current.matches(config.targetSelector)) {
return current;
}

current = current.parentElement;
}

return null;
}

function mouseMoveHandler(event) {
moveCursor(event.clientX, event.clientY);
}

function mouseOverHandler(event) {
enterTarget(findTarget(event.target));
}

function scrollHandler() {
if (!activeTarget) {
return;
}

var mouseX = Number(gsap.getProperty(cursor, 'x'));
var mouseY = Number(gsap.getProperty(cursor, 'y'));
var elementUnderMouse = document.elementFromPoint(mouseX, mouseY);
var stillOverTarget =
elementUnderMouse &&
(elementUnderMouse === activeTarget ||
elementUnderMouse.closest(config.targetSelector) === activeTarget);

if (!stillOverTarget && leaveHandler) {
leaveHandler();
}
}

function mouseDownHandler() {
if (!dot) {
return;
}

gsap.to(dot, { scale: 0.7, duration: 0.2, overwrite: 'auto' });
gsap.to(cursor, { scale: 0.9, duration: 0.2, overwrite: 'auto' });
}

function mouseUpHandler() {
if (!dot) {
return;
}

gsap.to(dot, { scale: 1, duration: 0.2, overwrite: 'auto' });
gsap.to(cursor, { scale: 1, duration: 0.2, overwrite: 'auto' });
}

function pjaxSendHandler() {
clearHoverState();
}

function pjaxCompleteHandler() {
if (!document.body.contains(cursor)) {
document.body.appendChild(cursor);
}
}

if (config.hideDefaultCursor) {
document.body.style.cursor = 'none';
}

gsap.set(cursor, {
xPercent: -50,
yPercent: -50,
x: window.innerWidth / 2,
y: window.innerHeight / 2
});

createSpinTimeline();
resetCorners();

window.addEventListener('mousemove', mouseMoveHandler);
window.addEventListener('mouseover', mouseOverHandler, { passive: true });
window.addEventListener('scroll', scrollHandler, { passive: true });
window.addEventListener('mousedown', mouseDownHandler);
window.addEventListener('mouseup', mouseUpHandler);
document.addEventListener('pjax:send', pjaxSendHandler);
document.addEventListener('pjax:complete', pjaxCompleteHandler);

window.__targetCursorHexoDestroy = function () {
if (resumeTimeout) {
clearTimeout(resumeTimeout);
}

gsap.ticker.remove(tickerFn);
cleanupActiveTarget();
spinTl && spinTl.kill();

window.removeEventListener('mousemove', mouseMoveHandler);
window.removeEventListener('mouseover', mouseOverHandler);
window.removeEventListener('scroll', scrollHandler);
window.removeEventListener('mousedown', mouseDownHandler);
window.removeEventListener('mouseup', mouseUpHandler);
document.removeEventListener('pjax:send', pjaxSendHandler);
document.removeEventListener('pjax:complete', pjaxCompleteHandler);

document.body.style.cursor = originalCursor;

if (cursor.parentNode) {
cursor.parentNode.removeChild(cursor);
}

window.__targetCursorHexoDestroy = null;
window.__targetCursorHexoLoaded = false;
};
}

function enableTargetCursor() {
writeCursorEnabledState(true);
syncCursorEnabledState(true);
setupTargetCursor();
}

function disableTargetCursor() {
writeCursorEnabledState(false);

if (window.__targetCursorHexoDestroy) {
window.__targetCursorHexoDestroy();
} else {
window.__targetCursorHexoLoaded = false;
}

syncCursorEnabledState(false);
}

function toggleTargetCursor(forceState) {
var nextState =
typeof forceState === 'boolean' ? forceState : !readCursorEnabledState();

if (nextState) {
enableTargetCursor();
} else {
disableTargetCursor();
}

return nextState;
}

window.TargetCursorController = {
enable: enableTargetCursor,
disable: disableTargetCursor,
toggle: toggleTargetCursor,
isEnabled: readCursorEnabledState
};

if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
function () {
syncCursorEnabledState(readCursorEnabledState());
setupTargetCursor();
},
{ once: true }
);
} else {
syncCursorEnabledState(readCursorEnabledState());
setupTargetCursor();
}
})();
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
// cursor-toggle.js

(function () {
if (window.__cursorToggleBootstrapped) {
return;
}

window.__cursorToggleBootstrapped = true;

function renderCursorToggle() {
var button = document.getElementById('cursor-toggle');

if (!button || !window.TargetCursorController) {
return;
}

var enabled = window.TargetCursorController.isEnabled();
var icon = button.querySelector('i');

button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
button.setAttribute('title', enabled ? '关闭鼠标特效' : '开启鼠标特效');
button.classList.toggle('is-disabled', !enabled);

if (icon) {
icon.classList.toggle('fa-mouse-pointer', enabled);
icon.classList.toggle('fa-ban', !enabled);
}
}

function bindCursorToggle() {
var button = document.getElementById('cursor-toggle');

if (!button || !window.TargetCursorController) {
return;
}

if (button.dataset.cursorToggleBound !== '1') {
button.dataset.cursorToggleBound = '1';
button.addEventListener('click', function () {
window.TargetCursorController.toggle();
renderCursorToggle();
});
}

renderCursorToggle();
}

document.addEventListener('target-cursor:change', renderCursorToggle);
document.addEventListener('pjax:complete', bindCursorToggle);

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindCursorToggle, { once: true });
} else {
bindCursorToggle();
}
})();

CSS

可修改的光标颜色:

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
75
76
/* target-cursor.css */

:root {
/* --target-cursor-dot-color: #28c28c; */
--target-cursor-dot-color: #ff80ac;
--target-cursor-corner-color: #ff80ac;
--target-cursor-z-index: 9999;
}

.target-cursor-wrapper {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
pointer-events: none;
z-index: var(--target-cursor-z-index);
transform: translate(-50%, -50%);
}

.target-cursor-rotor {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
will-change: transform;
}

.target-cursor-dot {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 4px;
background: var(--target-cursor-dot-color);
border-radius: 50%;
transform: translate(-50%, -50%);
will-change: transform;
box-shadow: 0 0 12px rgba(255, 128, 172, 0.45);
}

.target-cursor-corner {
position: absolute;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
border: 3px solid var(--target-cursor-corner-color);
will-change: transform;
filter: drop-shadow(0 0 8px rgba(40, 194, 140, 0.35));
}

.target-cursor-corner.corner-tl {
transform: translate(-150%, -150%);
border-right: none;
border-bottom: none;
}

.target-cursor-corner.corner-tr {
transform: translate(50%, -150%);
border-left: none;
border-bottom: none;
}

.target-cursor-corner.corner-br {
transform: translate(50%, 50%);
border-left: none;
border-top: none;
}

.target-cursor-corner.corner-bl {
transform: translate(-150%, 50%);
border-right: none;
border-top: none;
}

Butterfly配置

在Butterfly的配置文件内加上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
inject:
head:
- '<link rel="stylesheet" href="/css/target-cursor.css">'
bottom:
- <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
- <script src="/js/target-cursor.js"></script>
- <script src="/js/cursor-toggle.js"></script>

# 额外的配置 用于右下角显示控制开关
# 显式 false 关闭
cursor_conf: true

rightside_item_order:
enable: true
hide: readmode,translate,hideAside,cursor
show: toc,darkmode,chat,comment

再修改一下rightside.pug

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
- const { readmode, translate, darkmode, aside, chat_btn } = theme
- const cursor_conf = theme.cursor_conf !== false
mixin rightsideItem(array)
each item in array
case item
when 'readmode'
if is_post() && readmode
button#readmode(type="button" title=_p('rightside.readmode_title'))
i.fas.fa-book-open
when 'translate'
if translate.enable
button#translateLink(type="button" title=_p('rightside.translate_title'))= translate.default
when 'darkmode'
if darkmode.enable && darkmode.button
button#darkmode(type="button" title=_p('rightside.night_mode_title'))
i.fas.fa-adjust
when 'hideAside'
if aside.enable && aside.button && page.aside !== false
button#hide-aside-btn(type="button" title=_p('rightside.aside'))
i.fas.fa-arrows-alt-h
when 'toc'
if showToc
button#mobile-toc-button.close(type="button" title=_p("rightside.toc"))
i.fas.fa-list-ul
when 'chat'
if chat_btn
button#chat_btn(type="button" title=_p("rightside.chat"))
i.fas.fa-sms
when 'comment'
if commentsJsLoad
a#to_comment(href="#post-comment" title=_p("rightside.scroll_to_comment"))
i.fas.fa-comments
when 'cursor'
if cursor_conf
button#cursor-toggle(type="button" title="切换鼠标特效" aria-pressed="true")
i.fas.fa-mouse-pointer

#rightside
- const { enable, hide, show } = theme.rightside_item_order
- const hideArray = enable ? hide && hide.split(',').map(item => item.trim()) : ['readmode','translate','darkmode','hideAside']
- const showArray = enable ? show && show.split(',').map(item => item.trim()) : ['toc','chat','comment','cursor']



#rightside-config-hide
if hideArray
+rightsideItem(hideArray)
#rightside-config-show
if enable
if hide
button#rightside_config(type="button" title=_p("rightside.setting"))
i.fas.fa-cog.fa-spin
else
if is_post()
if (readmode || translate.enable || (darkmode.enable && darkmode.button))
button#rightside_config(type="button" title=_p("rightside.setting"))
i.fas.fa-cog.fa-spin
else if translate.enable || (darkmode.enable && darkmode.button)
button#rightside_config(type="button" title=_p("rightside.setting"))
i.fas.fa-cog.fa-spin

if showArray
+rightsideItem(showArray)

button#go-up(type="button" title=_p("rightside.back_to_top"))
span.scroll-percent
i.fas.fa-arrow-up

即可体验。