anime-cursor 2.0.0 → 2.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.
- package/README.md +34 -2
- package/dist/anime-cursor.esm.js +81 -65
- package/dist/anime-cursor.umd.js +81 -65
- package/dist/anime-cursor.umd.min.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ AnimeCursor has no dependencies on any frameworks, making it suitable for person
|
|
|
33
33
|
### CDN
|
|
34
34
|
|
|
35
35
|
```html
|
|
36
|
-
<script src="https://cdn.jsdelivr.net/npm/anime-cursor@v2.
|
|
36
|
+
<script src="https://cdn.jsdelivr.net/npm/anime-cursor@v2.1.0/dist/anime-cursor.umd.min.js"></script>
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
### npm
|
|
@@ -125,6 +125,7 @@ An object that defines all cursor types. Each key is a cursor name (any string).
|
|
|
125
125
|
|
|
126
126
|
| Option | Type | Default | Description |
|
|
127
127
|
| ------------------ | ------- | -------------------------------------- | ------------------------------------------------------------ |
|
|
128
|
+
| `combineAnimations` | boolean | `false` | Automatically merges cursor animations with element‑defined animations (via `data-ac-animation`). When enabled, any element with the `data-ac-animation` attribute will have its own CSS animation combined with the cursor animation, allowing both to run simultaneously without overriding each other. |
|
|
128
129
|
| `debug` | boolean | `false` | Enables a debug overlay showing current cursor type and coordinates. |
|
|
129
130
|
| `enableTouch` | boolean | `false` | Allow animated cursors on touch devices (detected automatically, disabled by default). |
|
|
130
131
|
| `fallbackCursor` | string | `'auto'` | Global fallback cursor for all animated cursors (e.g., `'auto'`, `'pointer'`). |
|
|
@@ -149,6 +150,21 @@ An object that defines all cursor types. Each key is a cursor name (any string).
|
|
|
149
150
|
- **Variable Speed Animation**: `frames` and `duration` are arrays of equal length. Each segment defines a number of frames and the time to play them. The frames are evenly distributed within each segment.
|
|
150
151
|
- **Static Cursor**: If `frames` or `duration` is missing, or if they are invalid (e.g., non-positive numbers, mismatched array lengths), the cursor is treated as static (only the first image is used). A warning will be logged in the console.
|
|
151
152
|
|
|
153
|
+
### 🎞️ Combining Animations
|
|
154
|
+
|
|
155
|
+
If your elements already have their own CSS animations (e.g., `animation: spin 2s infinite`), they may override AnimeCursor's cursor animations. To make both play together, enable the `combineAnimations` global option and add a `data-ac-animation` attribute to the element with your animation definition(s). AnimeCursor will then automatically combine them.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
|
|
159
|
+
```html
|
|
160
|
+
<button data-ac-animation="mySpin 2s linear infinite">Click Me</button>
|
|
161
|
+
new AnimeCursor({
|
|
162
|
+
combineAnimations: true,
|
|
163
|
+
cursors: { ... }
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
Multiple animations can be listed, separated by commas.
|
|
167
|
+
|
|
152
168
|
### 🧩 Tagging Mechanism
|
|
153
169
|
|
|
154
170
|
AnimeCursor automatically adds the appropriate cursor style to elements based on `tags` and `data-cursor` attributes:
|
|
@@ -202,7 +218,7 @@ AnimeCursor 无需依赖任何框架,适合个人网站、创意作品集以
|
|
|
202
218
|
### CDN
|
|
203
219
|
|
|
204
220
|
```html
|
|
205
|
-
<script src="https://cdn.jsdelivr.net/npm/anime-cursor@v2.
|
|
221
|
+
<script src="https://cdn.jsdelivr.net/npm/anime-cursor@v2.1.0/dist/anime-cursor.umd.min.js"></script>
|
|
206
222
|
```
|
|
207
223
|
|
|
208
224
|
### npm
|
|
@@ -294,6 +310,7 @@ new AnimeCursor({
|
|
|
294
310
|
|
|
295
311
|
| 选项 | 类型 | 默认值 | 描述 |
|
|
296
312
|
| ------------------ | ------- | -------------------------------------- | ------------------------------------------------------------ |
|
|
313
|
+
| `combineAnimations` | boolean | `false` | 自动将光标动画与元素自身定义的动画(通过 `data-ac-animation`)合并。开启后,任何带有 `data-ac-animation` 属性的元素,其光标动画与用户动画会同时播放,互不覆盖。 |
|
|
297
314
|
| `debug` | boolean | `false` | 启用调试浮层,显示当前光标类型和坐标。 |
|
|
298
315
|
| `enableTouch` | boolean | `false` | 允许在触屏设备上显示动画光标(默认自动检测并禁用)。 |
|
|
299
316
|
| `fallbackCursor` | string | `'auto'` | 全局备用光标类型,用于所有动画光标(如 `'auto'`、`'pointer'`)。 |
|
|
@@ -318,6 +335,21 @@ new AnimeCursor({
|
|
|
318
335
|
- **变速动画**:`frames` 和 `duration` 均为等长数组。每个片段指定帧数和该片段的时长,片段内的帧均匀分布。
|
|
319
336
|
- **静态光标**:如果缺少 `frames` 或 `duration`,或者设置无效(如非正数、数组长度不匹配),则光标被视为静态(仅使用第一帧图片)。控制台会输出警告。
|
|
320
337
|
|
|
338
|
+
### 🎞️ 组合动画
|
|
339
|
+
|
|
340
|
+
如果元素自身已经拥有 CSS 动画(例如 `animation: spin 2s infinite`),这些动画可能会覆盖 AnimeCursor 的光标动画。要让两者同时播放,请启用 `combineAnimations` 全局选项,并为元素添加 `data-ac-animation` 属性,将你的动画定义写入其中。AnimeCursor 会自动将两者组合。
|
|
341
|
+
|
|
342
|
+
示例:
|
|
343
|
+
|
|
344
|
+
```html
|
|
345
|
+
<button data-ac-animation="mySpin 2s linear infinite">点我</button>
|
|
346
|
+
new AnimeCursor({
|
|
347
|
+
combineAnimations: true,
|
|
348
|
+
cursors: { ... }
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
多个动画可用逗号分隔。
|
|
352
|
+
|
|
321
353
|
### 🧩 标记机制
|
|
322
354
|
|
|
323
355
|
AnimeCursor 会根据 `tags` 和 `data-cursor` 自动应用光标样式:
|
package/dist/anime-cursor.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AnimeCursor by github@ShuninYu
|
|
2
|
-
// v2.
|
|
2
|
+
// v2.1.0
|
|
3
3
|
|
|
4
4
|
let _instance = null;
|
|
5
5
|
|
|
@@ -49,13 +49,16 @@ class AnimeCursor {
|
|
|
49
49
|
this.options = {
|
|
50
50
|
debug: false,
|
|
51
51
|
enableTouch: false,
|
|
52
|
-
fallbackCursor: 'auto',
|
|
53
|
-
excludeSelectors: 'input, textarea, [contenteditable]',
|
|
52
|
+
fallbackCursor: 'auto',
|
|
53
|
+
excludeSelectors: 'input, textarea, [contenteditable]',
|
|
54
|
+
combineAnimations: false, // 是否自动组合用户动画
|
|
54
55
|
...options
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
this.disabled = false;
|
|
58
59
|
this.cursors = this.options.cursors || {};
|
|
60
|
+
this.cursorAnimationStrings = {}; // 存储每个光标类型的动画字符串
|
|
61
|
+
this.combinedRules = new Map(); // 存储已生成的组合类名
|
|
59
62
|
|
|
60
63
|
// 检查是否应启用(触摸设备且未强制启用则禁用)
|
|
61
64
|
if (!this.options.enableTouch && !this.isMouseLikeDevice()) {
|
|
@@ -77,12 +80,10 @@ class AnimeCursor {
|
|
|
77
80
|
_instance = this;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
// 判断是否鼠标设备
|
|
81
83
|
isMouseLikeDevice() {
|
|
82
84
|
return window.matchMedia('(pointer: fine)').matches;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
// 验证配置
|
|
86
87
|
_validateOptions() {
|
|
87
88
|
if (this.disabled) return;
|
|
88
89
|
|
|
@@ -92,14 +93,12 @@ class AnimeCursor {
|
|
|
92
93
|
|
|
93
94
|
let hasDefault = false;
|
|
94
95
|
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
95
|
-
// 检查必填项
|
|
96
96
|
if (!cfg.image) {
|
|
97
97
|
throw new Error(`[AnimeCursor] Cursor "${name}" missing required setting: image`);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// 处理 frames 和 duration
|
|
100
|
+
// 处理 frames 和 duration
|
|
101
101
|
if (cfg.frames !== undefined && cfg.duration !== undefined) {
|
|
102
|
-
// 检查类型一致性
|
|
103
102
|
const framesType = typeof cfg.frames;
|
|
104
103
|
const durationType = typeof cfg.duration;
|
|
105
104
|
if (framesType !== durationType) {
|
|
@@ -107,13 +106,11 @@ class AnimeCursor {
|
|
|
107
106
|
delete cfg.frames;
|
|
108
107
|
delete cfg.duration;
|
|
109
108
|
} else if (Array.isArray(cfg.frames) && Array.isArray(cfg.duration)) {
|
|
110
|
-
// 数组形式:必须长度相等
|
|
111
109
|
if (cfg.frames.length !== cfg.duration.length) {
|
|
112
110
|
console.warn(`[AnimeCursor] Cursor "${name}" frames and duration arrays have different lengths, treating as static cursor`);
|
|
113
111
|
delete cfg.frames;
|
|
114
112
|
delete cfg.duration;
|
|
115
113
|
} else {
|
|
116
|
-
// 验证数组元素为正整数/正数
|
|
117
114
|
for (let f of cfg.frames) {
|
|
118
115
|
if (!Number.isInteger(f) || f <= 0) {
|
|
119
116
|
console.warn(`[AnimeCursor] Cursor "${name}" frames array contains invalid value, treating as static cursor`);
|
|
@@ -132,48 +129,37 @@ class AnimeCursor {
|
|
|
132
129
|
}
|
|
133
130
|
}
|
|
134
131
|
} else if (typeof cfg.frames === 'number' && typeof cfg.duration === 'number') {
|
|
135
|
-
// 数字形式:合法
|
|
136
132
|
if (cfg.frames <= 0 || cfg.duration <= 0) {
|
|
137
133
|
console.warn(`[AnimeCursor] Cursor "${name}" frames or duration <= 0, treating as static cursor`);
|
|
138
134
|
delete cfg.frames;
|
|
139
135
|
delete cfg.duration;
|
|
140
136
|
}
|
|
141
137
|
} else {
|
|
142
|
-
// 其他情况(如一个数字一个数组)
|
|
143
138
|
console.warn(`[AnimeCursor] Cursor "${name}" frames and duration must be both numbers or both arrays, treating as static cursor`);
|
|
144
139
|
delete cfg.frames;
|
|
145
140
|
delete cfg.duration;
|
|
146
141
|
}
|
|
147
142
|
} else if (cfg.frames !== undefined || cfg.duration !== undefined) {
|
|
148
|
-
// 只设置了一个
|
|
149
143
|
console.warn(`[AnimeCursor] Cursor "${name}" has only frames or duration defined, treating as static cursor`);
|
|
150
144
|
delete cfg.frames;
|
|
151
145
|
delete cfg.duration;
|
|
152
146
|
}
|
|
153
147
|
|
|
154
|
-
// 检查 tags
|
|
155
148
|
if (cfg.tags && !Array.isArray(cfg.tags)) {
|
|
156
149
|
throw new Error(`[AnimeCursor] Cursor "${name}" tags must be an array`);
|
|
157
150
|
}
|
|
158
|
-
// 检查 default
|
|
159
151
|
if (cfg.default) {
|
|
160
152
|
if (hasDefault) throw new Error('[AnimeCursor] Only one default cursor allowed');
|
|
161
153
|
hasDefault = true;
|
|
162
154
|
}
|
|
163
|
-
// 检查 offset
|
|
164
155
|
if (cfg.offset && (!Array.isArray(cfg.offset) || cfg.offset.length !== 2)) {
|
|
165
156
|
throw new Error(`[AnimeCursor] Cursor "${name}" offset must be [x, y] array`);
|
|
166
157
|
}
|
|
167
158
|
}
|
|
168
159
|
|
|
169
|
-
|
|
170
|
-
throw new Error('[AnimeCursor] A default cursor (default: true) must be defined');
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
this.defaultCursorName = Object.keys(this.cursors).find(name => this.cursors[name].default);
|
|
160
|
+
this.defaultCursorName = hasDefault ? Object.keys(this.cursors).find(name => this.cursors[name].default) : null;
|
|
174
161
|
}
|
|
175
162
|
|
|
176
|
-
// 预加载所有图片
|
|
177
163
|
_preloadImages() {
|
|
178
164
|
const images = new Set();
|
|
179
165
|
for (const cfg of Object.values(this.cursors)) {
|
|
@@ -195,10 +181,8 @@ class AnimeCursor {
|
|
|
195
181
|
}
|
|
196
182
|
}
|
|
197
183
|
|
|
198
|
-
// 根据配置生成所有帧的 URL 数组
|
|
199
184
|
_getFrameUrls(cfg) {
|
|
200
|
-
|
|
201
|
-
let totalFrames = 1; // 默认单帧
|
|
185
|
+
let totalFrames = 1;
|
|
202
186
|
if (cfg.frames !== undefined) {
|
|
203
187
|
if (Array.isArray(cfg.frames)) {
|
|
204
188
|
totalFrames = cfg.frames.reduce((a, b) => a + b, 0);
|
|
@@ -210,7 +194,6 @@ class AnimeCursor {
|
|
|
210
194
|
const { image } = cfg;
|
|
211
195
|
if (totalFrames === 1) return [image];
|
|
212
196
|
|
|
213
|
-
// 解析文件名模板
|
|
214
197
|
const { prefix, suffix, startNum, numFormat, ext } = this._parseImagePattern(image);
|
|
215
198
|
const urls = [];
|
|
216
199
|
for (let i = 0; i < totalFrames; i++) {
|
|
@@ -222,15 +205,12 @@ class AnimeCursor {
|
|
|
222
205
|
return urls;
|
|
223
206
|
}
|
|
224
207
|
|
|
225
|
-
// 解析图片路径,提取数字模板
|
|
226
208
|
_parseImagePattern(path) {
|
|
227
|
-
// 匹配最后一个数字部分(包括可能的前后括号/下划线等)
|
|
228
209
|
const extMatch = path.match(/\.[^.]+$/);
|
|
229
210
|
const ext = extMatch ? extMatch[0] : '';
|
|
230
211
|
const base = path.slice(0, -ext.length);
|
|
231
|
-
const numMatch = base.match(/(\d+)(?!.*\d)/);
|
|
212
|
+
const numMatch = base.match(/(\d+)(?!.*\d)/);
|
|
232
213
|
if (!numMatch) {
|
|
233
|
-
// 无数字,则默认在扩展名前加 _%d
|
|
234
214
|
return {
|
|
235
215
|
prefix: base + '_',
|
|
236
216
|
suffix: '',
|
|
@@ -241,10 +221,9 @@ class AnimeCursor {
|
|
|
241
221
|
}
|
|
242
222
|
const numStr = numMatch[0];
|
|
243
223
|
const startNum = parseInt(numStr, 10);
|
|
244
|
-
const numFormat = numStr.length;
|
|
224
|
+
const numFormat = numStr.length;
|
|
245
225
|
const prefix = base.slice(0, numMatch.index);
|
|
246
226
|
const suffix = base.slice(numMatch.index + numStr.length);
|
|
247
|
-
// 判断是否有包裹字符(如括号)
|
|
248
227
|
return { prefix, suffix, startNum, numFormat, ext };
|
|
249
228
|
}
|
|
250
229
|
|
|
@@ -252,7 +231,6 @@ class AnimeCursor {
|
|
|
252
231
|
return String(num).padStart(width, '0');
|
|
253
232
|
}
|
|
254
233
|
|
|
255
|
-
// 等待 DOM 加载
|
|
256
234
|
_checkDomLoad() {
|
|
257
235
|
const init = () => {
|
|
258
236
|
this._injectStyles();
|
|
@@ -266,7 +244,7 @@ class AnimeCursor {
|
|
|
266
244
|
}
|
|
267
245
|
}
|
|
268
246
|
|
|
269
|
-
//
|
|
247
|
+
// 核心注入样式
|
|
270
248
|
_injectStyles() {
|
|
271
249
|
if (this.disabled) return;
|
|
272
250
|
|
|
@@ -274,11 +252,12 @@ class AnimeCursor {
|
|
|
274
252
|
style.id = 'animecursor-styles';
|
|
275
253
|
let css = '';
|
|
276
254
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
255
|
+
// 如果有默认光标,生成全局规则
|
|
256
|
+
if (this.defaultCursorName) {
|
|
257
|
+
const defaultCfg = this.cursors[this.defaultCursorName];
|
|
258
|
+
const defaultCursorDef = this._buildCursorCss(this.defaultCursorName, defaultCfg);
|
|
259
|
+
css += `* { ${defaultCursorDef} }\n`;
|
|
260
|
+
}
|
|
282
261
|
|
|
283
262
|
// 为每个光标生成独立的类和关键帧
|
|
284
263
|
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
@@ -286,20 +265,17 @@ class AnimeCursor {
|
|
|
286
265
|
const offset = cfg.offset || [0, 0];
|
|
287
266
|
const fallback = cfg.fallback || this.options.fallbackCursor;
|
|
288
267
|
|
|
289
|
-
// 获取所有帧 URL
|
|
290
268
|
const frameUrls = this._getFrameUrls(cfg);
|
|
291
269
|
const frameCount = frameUrls.length;
|
|
292
270
|
|
|
293
|
-
// 判断是否有动画(有 frames 和 duration 且都有效)
|
|
294
271
|
const hasAnimation = cfg.frames !== undefined && cfg.duration !== undefined &&
|
|
295
272
|
((Array.isArray(cfg.frames) && Array.isArray(cfg.duration)) ||
|
|
296
273
|
(typeof cfg.frames === 'number' && typeof cfg.duration === 'number'));
|
|
297
274
|
|
|
275
|
+
let cursorAnimation = '';
|
|
298
276
|
if (hasAnimation && frameCount > 1) {
|
|
299
277
|
const keyframeName = `ac_anim_${name}`;
|
|
300
278
|
let keyframesCss = `@keyframes ${keyframeName} {\n`;
|
|
301
|
-
|
|
302
|
-
// 构建关键帧列表(百分比和对应图片)
|
|
303
279
|
const keyframes = this._buildKeyframes(cfg, frameUrls);
|
|
304
280
|
for (const kf of keyframes) {
|
|
305
281
|
let percent = (kf.percent * 100).toFixed(5);
|
|
@@ -310,30 +286,55 @@ class AnimeCursor {
|
|
|
310
286
|
keyframesCss += `}\n`;
|
|
311
287
|
css += keyframesCss;
|
|
312
288
|
|
|
313
|
-
// 应用动画的类
|
|
314
289
|
const totalDuration = Array.isArray(cfg.duration) ? cfg.duration.reduce((a, b) => a + b, 0) : cfg.duration;
|
|
315
290
|
const animation = `${keyframeName} ${totalDuration}s steps(1) infinite ${cfg.pingpong ? 'alternate' : ''}`;
|
|
291
|
+
cursorAnimation = animation;
|
|
316
292
|
css += `${className} { cursor: url("${frameUrls[0]}") ${offset[0]} ${offset[1]}, ${fallback}; animation: ${animation}; }\n`;
|
|
317
293
|
} else {
|
|
318
|
-
// 静态光标
|
|
319
294
|
css += `${className} { cursor: url("${frameUrls[0]}") ${offset[0]} ${offset[1]}, ${fallback}; }\n`;
|
|
320
295
|
}
|
|
321
296
|
|
|
322
|
-
|
|
297
|
+
this.cursorAnimationStrings[name] = cursorAnimation;
|
|
298
|
+
|
|
299
|
+
// 标签和 data-cursor 规则
|
|
323
300
|
if (cfg.tags && cfg.tags.length) {
|
|
324
301
|
const selector = cfg.tags.join(', ');
|
|
325
302
|
css += `${selector} { ${this._buildCursorCss(name, cfg)} }\n`;
|
|
326
303
|
}
|
|
327
|
-
// 支持 data-cursor 属性
|
|
328
304
|
css += `[data-cursor="${name}"] { ${this._buildCursorCss(name, cfg)} }\n`;
|
|
329
305
|
}
|
|
330
306
|
|
|
331
|
-
// 排除原生文本光标元素
|
|
332
307
|
if (this.options.excludeSelectors) {
|
|
333
308
|
css += `${this.options.excludeSelectors} { cursor: text !important; animation: none !important; }\n`;
|
|
334
309
|
}
|
|
335
310
|
|
|
336
|
-
//
|
|
311
|
+
// 自动组合动画(新功能)
|
|
312
|
+
if (this.options.combineAnimations) {
|
|
313
|
+
const elements = document.querySelectorAll('[data-ac-animation]');
|
|
314
|
+
for (const el of elements) {
|
|
315
|
+
const userAnim = el.getAttribute('data-ac-animation');
|
|
316
|
+
if (!userAnim) continue;
|
|
317
|
+
|
|
318
|
+
// 确定该元素应该使用哪个光标
|
|
319
|
+
let cursorName = this._getCursorTypeForElement(el);
|
|
320
|
+
if (!cursorName) continue;
|
|
321
|
+
|
|
322
|
+
const cursorAnim = this.cursorAnimationStrings[cursorName];
|
|
323
|
+
if (!cursorAnim) continue;
|
|
324
|
+
|
|
325
|
+
// 生成唯一标识
|
|
326
|
+
const key = `${cursorName}:${userAnim}`;
|
|
327
|
+
if (!this.combinedRules.has(key)) {
|
|
328
|
+
const hash = this._simpleHash(key);
|
|
329
|
+
const combinedClass = `ac-combined-${hash}`;
|
|
330
|
+
css += `.${combinedClass} { animation: ${cursorAnim}, ${userAnim}; }\n`;
|
|
331
|
+
this.combinedRules.set(key, combinedClass);
|
|
332
|
+
}
|
|
333
|
+
const combinedClass = this.combinedRules.get(key);
|
|
334
|
+
el.classList.add(combinedClass);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
337
338
|
css += `body.animecursor-disabled * { cursor: auto !important; animation: none !important; }\n`;
|
|
338
339
|
|
|
339
340
|
style.textContent = css;
|
|
@@ -341,20 +342,29 @@ class AnimeCursor {
|
|
|
341
342
|
this.styleEl = style;
|
|
342
343
|
}
|
|
343
344
|
|
|
344
|
-
//
|
|
345
|
+
// 获取元素对应的光标类型(复用 debug 逻辑)
|
|
346
|
+
_getCursorTypeForElement(el) {
|
|
347
|
+
if (el.dataset.cursor && this.cursors[el.dataset.cursor]) {
|
|
348
|
+
return el.dataset.cursor;
|
|
349
|
+
}
|
|
350
|
+
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
351
|
+
if (cfg.tags && cfg.tags.some(tag => el.matches(tag))) {
|
|
352
|
+
return name;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return this.defaultCursorName; // 可能为 null
|
|
356
|
+
}
|
|
357
|
+
|
|
345
358
|
_buildKeyframes(cfg, frameUrls) {
|
|
346
359
|
let frames = cfg.frames;
|
|
347
360
|
let durations = cfg.duration;
|
|
348
361
|
const frameCount = frameUrls.length;
|
|
349
362
|
|
|
350
|
-
// 统一转换为数组形式,方便处理
|
|
351
363
|
if (typeof frames === 'number') {
|
|
352
|
-
// 均匀分配
|
|
353
364
|
const perFrameDuration = durations / frames;
|
|
354
365
|
frames = new Array(frames).fill(1);
|
|
355
366
|
durations = new Array(frames.length).fill(perFrameDuration);
|
|
356
367
|
}
|
|
357
|
-
// 此时 frames 和 durations 都是等长数组
|
|
358
368
|
|
|
359
369
|
const keyframes = [];
|
|
360
370
|
let totalTime = durations.reduce((a, b) => a + b, 0);
|
|
@@ -363,7 +373,7 @@ class AnimeCursor {
|
|
|
363
373
|
for (let seg = 0; seg < frames.length; seg++) {
|
|
364
374
|
const segFrames = frames[seg];
|
|
365
375
|
const segDuration = durations[seg];
|
|
366
|
-
const stepTime = segDuration / segFrames;
|
|
376
|
+
const stepTime = segDuration / segFrames;
|
|
367
377
|
for (let f = 0; f < segFrames; f++) {
|
|
368
378
|
const percent = currentTime / totalTime;
|
|
369
379
|
keyframes.push({
|
|
@@ -374,7 +384,6 @@ class AnimeCursor {
|
|
|
374
384
|
frameIdx++;
|
|
375
385
|
}
|
|
376
386
|
}
|
|
377
|
-
// 确保最后一帧在 100%
|
|
378
387
|
keyframes.push({
|
|
379
388
|
percent: 1.0,
|
|
380
389
|
url: frameUrls[frameCount - 1]
|
|
@@ -382,13 +391,11 @@ class AnimeCursor {
|
|
|
382
391
|
return keyframes;
|
|
383
392
|
}
|
|
384
393
|
|
|
385
|
-
// 生成单个光标的 CSS 声明(不包含动画,用于选择器规则)
|
|
386
394
|
_buildCursorCss(name, cfg) {
|
|
387
395
|
const frameUrls = this._getFrameUrls(cfg);
|
|
388
396
|
const offset = cfg.offset || [0, 0];
|
|
389
397
|
const fallback = cfg.fallback || this.options.fallbackCursor;
|
|
390
398
|
let css = `cursor: url("${frameUrls[0]}") ${offset[0]} ${offset[1]}, ${fallback};`;
|
|
391
|
-
// 如果有动画,附加动画属性
|
|
392
399
|
const hasAnimation = cfg.frames !== undefined && cfg.duration !== undefined &&
|
|
393
400
|
((Array.isArray(cfg.frames) && Array.isArray(cfg.duration)) ||
|
|
394
401
|
(typeof cfg.frames === 'number' && typeof cfg.duration === 'number'));
|
|
@@ -399,7 +406,16 @@ class AnimeCursor {
|
|
|
399
406
|
return css;
|
|
400
407
|
}
|
|
401
408
|
|
|
402
|
-
|
|
409
|
+
_simpleHash(str) {
|
|
410
|
+
let hash = 0;
|
|
411
|
+
for (let i = 0; i < str.length; i++) {
|
|
412
|
+
const char = str.charCodeAt(i);
|
|
413
|
+
hash = ((hash << 5) - hash) + char;
|
|
414
|
+
hash |= 0;
|
|
415
|
+
}
|
|
416
|
+
return Math.abs(hash).toString(36);
|
|
417
|
+
}
|
|
418
|
+
|
|
403
419
|
_initDebug() {
|
|
404
420
|
const debugDiv = document.createElement('div');
|
|
405
421
|
debugDiv.className = 'animecursor-debug';
|
|
@@ -422,12 +438,11 @@ class AnimeCursor {
|
|
|
422
438
|
let lastCursor = '';
|
|
423
439
|
this._onMouseMove = (e) => {
|
|
424
440
|
const target = document.elementFromPoint(e.clientX, e.clientY);
|
|
425
|
-
let cursorType =
|
|
441
|
+
let cursorType = null;
|
|
426
442
|
if (target) {
|
|
427
443
|
if (target.dataset.cursor && this.cursors[target.dataset.cursor]) {
|
|
428
444
|
cursorType = target.dataset.cursor;
|
|
429
445
|
} else {
|
|
430
|
-
// 检查匹配 tags
|
|
431
446
|
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
432
447
|
if (cfg.tags && cfg.tags.some(tag => target.matches(tag))) {
|
|
433
448
|
cursorType = name;
|
|
@@ -436,6 +451,11 @@ class AnimeCursor {
|
|
|
436
451
|
}
|
|
437
452
|
}
|
|
438
453
|
}
|
|
454
|
+
if (!cursorType && !this.defaultCursorName) {
|
|
455
|
+
cursorType = 'native';
|
|
456
|
+
} else if (!cursorType && this.defaultCursorName) {
|
|
457
|
+
cursorType = this.defaultCursorName;
|
|
458
|
+
}
|
|
439
459
|
if (cursorType !== lastCursor) {
|
|
440
460
|
lastCursor = cursorType;
|
|
441
461
|
debugDiv.textContent = `🎯 ${cursorType} @ (${e.clientX}, ${e.clientY})`;
|
|
@@ -446,10 +466,10 @@ class AnimeCursor {
|
|
|
446
466
|
document.addEventListener('mousemove', this._onMouseMove);
|
|
447
467
|
}
|
|
448
468
|
|
|
449
|
-
// 刷新:重新注入样式(用于动态添加新光标等场景)
|
|
450
469
|
refresh() {
|
|
451
470
|
if (this.disabled) return;
|
|
452
471
|
if (this.styleEl) this.styleEl.remove();
|
|
472
|
+
this.combinedRules.clear();
|
|
453
473
|
this._injectStyles();
|
|
454
474
|
if (this.options.debug) {
|
|
455
475
|
if (this.debugEl) this.debugEl.remove();
|
|
@@ -458,7 +478,6 @@ class AnimeCursor {
|
|
|
458
478
|
console.log('[AnimeCursor] Refresh complete');
|
|
459
479
|
}
|
|
460
480
|
|
|
461
|
-
// 销毁实例
|
|
462
481
|
destroy() {
|
|
463
482
|
if (this.disabled) return;
|
|
464
483
|
if (this.styleEl) this.styleEl.remove();
|
|
@@ -466,13 +485,11 @@ class AnimeCursor {
|
|
|
466
485
|
if (this._onMouseMove) {
|
|
467
486
|
document.removeEventListener('mousemove', this._onMouseMove);
|
|
468
487
|
}
|
|
469
|
-
// 清除全局禁用类
|
|
470
488
|
document.body.classList.remove('animecursor-disabled');
|
|
471
489
|
_instance = null;
|
|
472
490
|
console.log('[AnimeCursor] Destroyed');
|
|
473
491
|
}
|
|
474
492
|
|
|
475
|
-
// 禁用光标动画
|
|
476
493
|
disable() {
|
|
477
494
|
if (this.disabled) return;
|
|
478
495
|
this.disabled = true;
|
|
@@ -480,7 +497,6 @@ class AnimeCursor {
|
|
|
480
497
|
if (this.options.debug) console.log('[AnimeCursor] Disabled');
|
|
481
498
|
}
|
|
482
499
|
|
|
483
|
-
// 启用光标动画
|
|
484
500
|
enable() {
|
|
485
501
|
if (!this.disabled) return;
|
|
486
502
|
this.disabled = false;
|
package/dist/anime-cursor.umd.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
})(this, (function () { 'use strict';
|
|
6
6
|
|
|
7
7
|
// AnimeCursor by github@ShuninYu
|
|
8
|
-
// v2.
|
|
8
|
+
// v2.1.0
|
|
9
9
|
|
|
10
10
|
let _instance = null;
|
|
11
11
|
|
|
@@ -55,13 +55,16 @@
|
|
|
55
55
|
this.options = {
|
|
56
56
|
debug: false,
|
|
57
57
|
enableTouch: false,
|
|
58
|
-
fallbackCursor: 'auto',
|
|
59
|
-
excludeSelectors: 'input, textarea, [contenteditable]',
|
|
58
|
+
fallbackCursor: 'auto',
|
|
59
|
+
excludeSelectors: 'input, textarea, [contenteditable]',
|
|
60
|
+
combineAnimations: false, // 是否自动组合用户动画
|
|
60
61
|
...options
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
this.disabled = false;
|
|
64
65
|
this.cursors = this.options.cursors || {};
|
|
66
|
+
this.cursorAnimationStrings = {}; // 存储每个光标类型的动画字符串
|
|
67
|
+
this.combinedRules = new Map(); // 存储已生成的组合类名
|
|
65
68
|
|
|
66
69
|
// 检查是否应启用(触摸设备且未强制启用则禁用)
|
|
67
70
|
if (!this.options.enableTouch && !this.isMouseLikeDevice()) {
|
|
@@ -83,12 +86,10 @@
|
|
|
83
86
|
_instance = this;
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
// 判断是否鼠标设备
|
|
87
89
|
isMouseLikeDevice() {
|
|
88
90
|
return window.matchMedia('(pointer: fine)').matches;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
// 验证配置
|
|
92
93
|
_validateOptions() {
|
|
93
94
|
if (this.disabled) return;
|
|
94
95
|
|
|
@@ -98,14 +99,12 @@
|
|
|
98
99
|
|
|
99
100
|
let hasDefault = false;
|
|
100
101
|
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
101
|
-
// 检查必填项
|
|
102
102
|
if (!cfg.image) {
|
|
103
103
|
throw new Error(`[AnimeCursor] Cursor "${name}" missing required setting: image`);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// 处理 frames 和 duration
|
|
106
|
+
// 处理 frames 和 duration
|
|
107
107
|
if (cfg.frames !== undefined && cfg.duration !== undefined) {
|
|
108
|
-
// 检查类型一致性
|
|
109
108
|
const framesType = typeof cfg.frames;
|
|
110
109
|
const durationType = typeof cfg.duration;
|
|
111
110
|
if (framesType !== durationType) {
|
|
@@ -113,13 +112,11 @@
|
|
|
113
112
|
delete cfg.frames;
|
|
114
113
|
delete cfg.duration;
|
|
115
114
|
} else if (Array.isArray(cfg.frames) && Array.isArray(cfg.duration)) {
|
|
116
|
-
// 数组形式:必须长度相等
|
|
117
115
|
if (cfg.frames.length !== cfg.duration.length) {
|
|
118
116
|
console.warn(`[AnimeCursor] Cursor "${name}" frames and duration arrays have different lengths, treating as static cursor`);
|
|
119
117
|
delete cfg.frames;
|
|
120
118
|
delete cfg.duration;
|
|
121
119
|
} else {
|
|
122
|
-
// 验证数组元素为正整数/正数
|
|
123
120
|
for (let f of cfg.frames) {
|
|
124
121
|
if (!Number.isInteger(f) || f <= 0) {
|
|
125
122
|
console.warn(`[AnimeCursor] Cursor "${name}" frames array contains invalid value, treating as static cursor`);
|
|
@@ -138,48 +135,37 @@
|
|
|
138
135
|
}
|
|
139
136
|
}
|
|
140
137
|
} else if (typeof cfg.frames === 'number' && typeof cfg.duration === 'number') {
|
|
141
|
-
// 数字形式:合法
|
|
142
138
|
if (cfg.frames <= 0 || cfg.duration <= 0) {
|
|
143
139
|
console.warn(`[AnimeCursor] Cursor "${name}" frames or duration <= 0, treating as static cursor`);
|
|
144
140
|
delete cfg.frames;
|
|
145
141
|
delete cfg.duration;
|
|
146
142
|
}
|
|
147
143
|
} else {
|
|
148
|
-
// 其他情况(如一个数字一个数组)
|
|
149
144
|
console.warn(`[AnimeCursor] Cursor "${name}" frames and duration must be both numbers or both arrays, treating as static cursor`);
|
|
150
145
|
delete cfg.frames;
|
|
151
146
|
delete cfg.duration;
|
|
152
147
|
}
|
|
153
148
|
} else if (cfg.frames !== undefined || cfg.duration !== undefined) {
|
|
154
|
-
// 只设置了一个
|
|
155
149
|
console.warn(`[AnimeCursor] Cursor "${name}" has only frames or duration defined, treating as static cursor`);
|
|
156
150
|
delete cfg.frames;
|
|
157
151
|
delete cfg.duration;
|
|
158
152
|
}
|
|
159
153
|
|
|
160
|
-
// 检查 tags
|
|
161
154
|
if (cfg.tags && !Array.isArray(cfg.tags)) {
|
|
162
155
|
throw new Error(`[AnimeCursor] Cursor "${name}" tags must be an array`);
|
|
163
156
|
}
|
|
164
|
-
// 检查 default
|
|
165
157
|
if (cfg.default) {
|
|
166
158
|
if (hasDefault) throw new Error('[AnimeCursor] Only one default cursor allowed');
|
|
167
159
|
hasDefault = true;
|
|
168
160
|
}
|
|
169
|
-
// 检查 offset
|
|
170
161
|
if (cfg.offset && (!Array.isArray(cfg.offset) || cfg.offset.length !== 2)) {
|
|
171
162
|
throw new Error(`[AnimeCursor] Cursor "${name}" offset must be [x, y] array`);
|
|
172
163
|
}
|
|
173
164
|
}
|
|
174
165
|
|
|
175
|
-
|
|
176
|
-
throw new Error('[AnimeCursor] A default cursor (default: true) must be defined');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
this.defaultCursorName = Object.keys(this.cursors).find(name => this.cursors[name].default);
|
|
166
|
+
this.defaultCursorName = hasDefault ? Object.keys(this.cursors).find(name => this.cursors[name].default) : null;
|
|
180
167
|
}
|
|
181
168
|
|
|
182
|
-
// 预加载所有图片
|
|
183
169
|
_preloadImages() {
|
|
184
170
|
const images = new Set();
|
|
185
171
|
for (const cfg of Object.values(this.cursors)) {
|
|
@@ -201,10 +187,8 @@
|
|
|
201
187
|
}
|
|
202
188
|
}
|
|
203
189
|
|
|
204
|
-
// 根据配置生成所有帧的 URL 数组
|
|
205
190
|
_getFrameUrls(cfg) {
|
|
206
|
-
|
|
207
|
-
let totalFrames = 1; // 默认单帧
|
|
191
|
+
let totalFrames = 1;
|
|
208
192
|
if (cfg.frames !== undefined) {
|
|
209
193
|
if (Array.isArray(cfg.frames)) {
|
|
210
194
|
totalFrames = cfg.frames.reduce((a, b) => a + b, 0);
|
|
@@ -216,7 +200,6 @@
|
|
|
216
200
|
const { image } = cfg;
|
|
217
201
|
if (totalFrames === 1) return [image];
|
|
218
202
|
|
|
219
|
-
// 解析文件名模板
|
|
220
203
|
const { prefix, suffix, startNum, numFormat, ext } = this._parseImagePattern(image);
|
|
221
204
|
const urls = [];
|
|
222
205
|
for (let i = 0; i < totalFrames; i++) {
|
|
@@ -228,15 +211,12 @@
|
|
|
228
211
|
return urls;
|
|
229
212
|
}
|
|
230
213
|
|
|
231
|
-
// 解析图片路径,提取数字模板
|
|
232
214
|
_parseImagePattern(path) {
|
|
233
|
-
// 匹配最后一个数字部分(包括可能的前后括号/下划线等)
|
|
234
215
|
const extMatch = path.match(/\.[^.]+$/);
|
|
235
216
|
const ext = extMatch ? extMatch[0] : '';
|
|
236
217
|
const base = path.slice(0, -ext.length);
|
|
237
|
-
const numMatch = base.match(/(\d+)(?!.*\d)/);
|
|
218
|
+
const numMatch = base.match(/(\d+)(?!.*\d)/);
|
|
238
219
|
if (!numMatch) {
|
|
239
|
-
// 无数字,则默认在扩展名前加 _%d
|
|
240
220
|
return {
|
|
241
221
|
prefix: base + '_',
|
|
242
222
|
suffix: '',
|
|
@@ -247,10 +227,9 @@
|
|
|
247
227
|
}
|
|
248
228
|
const numStr = numMatch[0];
|
|
249
229
|
const startNum = parseInt(numStr, 10);
|
|
250
|
-
const numFormat = numStr.length;
|
|
230
|
+
const numFormat = numStr.length;
|
|
251
231
|
const prefix = base.slice(0, numMatch.index);
|
|
252
232
|
const suffix = base.slice(numMatch.index + numStr.length);
|
|
253
|
-
// 判断是否有包裹字符(如括号)
|
|
254
233
|
return { prefix, suffix, startNum, numFormat, ext };
|
|
255
234
|
}
|
|
256
235
|
|
|
@@ -258,7 +237,6 @@
|
|
|
258
237
|
return String(num).padStart(width, '0');
|
|
259
238
|
}
|
|
260
239
|
|
|
261
|
-
// 等待 DOM 加载
|
|
262
240
|
_checkDomLoad() {
|
|
263
241
|
const init = () => {
|
|
264
242
|
this._injectStyles();
|
|
@@ -272,7 +250,7 @@
|
|
|
272
250
|
}
|
|
273
251
|
}
|
|
274
252
|
|
|
275
|
-
//
|
|
253
|
+
// 核心注入样式
|
|
276
254
|
_injectStyles() {
|
|
277
255
|
if (this.disabled) return;
|
|
278
256
|
|
|
@@ -280,11 +258,12 @@
|
|
|
280
258
|
style.id = 'animecursor-styles';
|
|
281
259
|
let css = '';
|
|
282
260
|
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
261
|
+
// 如果有默认光标,生成全局规则
|
|
262
|
+
if (this.defaultCursorName) {
|
|
263
|
+
const defaultCfg = this.cursors[this.defaultCursorName];
|
|
264
|
+
const defaultCursorDef = this._buildCursorCss(this.defaultCursorName, defaultCfg);
|
|
265
|
+
css += `* { ${defaultCursorDef} }\n`;
|
|
266
|
+
}
|
|
288
267
|
|
|
289
268
|
// 为每个光标生成独立的类和关键帧
|
|
290
269
|
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
@@ -292,20 +271,17 @@
|
|
|
292
271
|
const offset = cfg.offset || [0, 0];
|
|
293
272
|
const fallback = cfg.fallback || this.options.fallbackCursor;
|
|
294
273
|
|
|
295
|
-
// 获取所有帧 URL
|
|
296
274
|
const frameUrls = this._getFrameUrls(cfg);
|
|
297
275
|
const frameCount = frameUrls.length;
|
|
298
276
|
|
|
299
|
-
// 判断是否有动画(有 frames 和 duration 且都有效)
|
|
300
277
|
const hasAnimation = cfg.frames !== undefined && cfg.duration !== undefined &&
|
|
301
278
|
((Array.isArray(cfg.frames) && Array.isArray(cfg.duration)) ||
|
|
302
279
|
(typeof cfg.frames === 'number' && typeof cfg.duration === 'number'));
|
|
303
280
|
|
|
281
|
+
let cursorAnimation = '';
|
|
304
282
|
if (hasAnimation && frameCount > 1) {
|
|
305
283
|
const keyframeName = `ac_anim_${name}`;
|
|
306
284
|
let keyframesCss = `@keyframes ${keyframeName} {\n`;
|
|
307
|
-
|
|
308
|
-
// 构建关键帧列表(百分比和对应图片)
|
|
309
285
|
const keyframes = this._buildKeyframes(cfg, frameUrls);
|
|
310
286
|
for (const kf of keyframes) {
|
|
311
287
|
let percent = (kf.percent * 100).toFixed(5);
|
|
@@ -316,30 +292,55 @@
|
|
|
316
292
|
keyframesCss += `}\n`;
|
|
317
293
|
css += keyframesCss;
|
|
318
294
|
|
|
319
|
-
// 应用动画的类
|
|
320
295
|
const totalDuration = Array.isArray(cfg.duration) ? cfg.duration.reduce((a, b) => a + b, 0) : cfg.duration;
|
|
321
296
|
const animation = `${keyframeName} ${totalDuration}s steps(1) infinite ${cfg.pingpong ? 'alternate' : ''}`;
|
|
297
|
+
cursorAnimation = animation;
|
|
322
298
|
css += `${className} { cursor: url("${frameUrls[0]}") ${offset[0]} ${offset[1]}, ${fallback}; animation: ${animation}; }\n`;
|
|
323
299
|
} else {
|
|
324
|
-
// 静态光标
|
|
325
300
|
css += `${className} { cursor: url("${frameUrls[0]}") ${offset[0]} ${offset[1]}, ${fallback}; }\n`;
|
|
326
301
|
}
|
|
327
302
|
|
|
328
|
-
|
|
303
|
+
this.cursorAnimationStrings[name] = cursorAnimation;
|
|
304
|
+
|
|
305
|
+
// 标签和 data-cursor 规则
|
|
329
306
|
if (cfg.tags && cfg.tags.length) {
|
|
330
307
|
const selector = cfg.tags.join(', ');
|
|
331
308
|
css += `${selector} { ${this._buildCursorCss(name, cfg)} }\n`;
|
|
332
309
|
}
|
|
333
|
-
// 支持 data-cursor 属性
|
|
334
310
|
css += `[data-cursor="${name}"] { ${this._buildCursorCss(name, cfg)} }\n`;
|
|
335
311
|
}
|
|
336
312
|
|
|
337
|
-
// 排除原生文本光标元素
|
|
338
313
|
if (this.options.excludeSelectors) {
|
|
339
314
|
css += `${this.options.excludeSelectors} { cursor: text !important; animation: none !important; }\n`;
|
|
340
315
|
}
|
|
341
316
|
|
|
342
|
-
//
|
|
317
|
+
// 自动组合动画(新功能)
|
|
318
|
+
if (this.options.combineAnimations) {
|
|
319
|
+
const elements = document.querySelectorAll('[data-ac-animation]');
|
|
320
|
+
for (const el of elements) {
|
|
321
|
+
const userAnim = el.getAttribute('data-ac-animation');
|
|
322
|
+
if (!userAnim) continue;
|
|
323
|
+
|
|
324
|
+
// 确定该元素应该使用哪个光标
|
|
325
|
+
let cursorName = this._getCursorTypeForElement(el);
|
|
326
|
+
if (!cursorName) continue;
|
|
327
|
+
|
|
328
|
+
const cursorAnim = this.cursorAnimationStrings[cursorName];
|
|
329
|
+
if (!cursorAnim) continue;
|
|
330
|
+
|
|
331
|
+
// 生成唯一标识
|
|
332
|
+
const key = `${cursorName}:${userAnim}`;
|
|
333
|
+
if (!this.combinedRules.has(key)) {
|
|
334
|
+
const hash = this._simpleHash(key);
|
|
335
|
+
const combinedClass = `ac-combined-${hash}`;
|
|
336
|
+
css += `.${combinedClass} { animation: ${cursorAnim}, ${userAnim}; }\n`;
|
|
337
|
+
this.combinedRules.set(key, combinedClass);
|
|
338
|
+
}
|
|
339
|
+
const combinedClass = this.combinedRules.get(key);
|
|
340
|
+
el.classList.add(combinedClass);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
343
344
|
css += `body.animecursor-disabled * { cursor: auto !important; animation: none !important; }\n`;
|
|
344
345
|
|
|
345
346
|
style.textContent = css;
|
|
@@ -347,20 +348,29 @@
|
|
|
347
348
|
this.styleEl = style;
|
|
348
349
|
}
|
|
349
350
|
|
|
350
|
-
//
|
|
351
|
+
// 获取元素对应的光标类型(复用 debug 逻辑)
|
|
352
|
+
_getCursorTypeForElement(el) {
|
|
353
|
+
if (el.dataset.cursor && this.cursors[el.dataset.cursor]) {
|
|
354
|
+
return el.dataset.cursor;
|
|
355
|
+
}
|
|
356
|
+
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
357
|
+
if (cfg.tags && cfg.tags.some(tag => el.matches(tag))) {
|
|
358
|
+
return name;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return this.defaultCursorName; // 可能为 null
|
|
362
|
+
}
|
|
363
|
+
|
|
351
364
|
_buildKeyframes(cfg, frameUrls) {
|
|
352
365
|
let frames = cfg.frames;
|
|
353
366
|
let durations = cfg.duration;
|
|
354
367
|
const frameCount = frameUrls.length;
|
|
355
368
|
|
|
356
|
-
// 统一转换为数组形式,方便处理
|
|
357
369
|
if (typeof frames === 'number') {
|
|
358
|
-
// 均匀分配
|
|
359
370
|
const perFrameDuration = durations / frames;
|
|
360
371
|
frames = new Array(frames).fill(1);
|
|
361
372
|
durations = new Array(frames.length).fill(perFrameDuration);
|
|
362
373
|
}
|
|
363
|
-
// 此时 frames 和 durations 都是等长数组
|
|
364
374
|
|
|
365
375
|
const keyframes = [];
|
|
366
376
|
let totalTime = durations.reduce((a, b) => a + b, 0);
|
|
@@ -369,7 +379,7 @@
|
|
|
369
379
|
for (let seg = 0; seg < frames.length; seg++) {
|
|
370
380
|
const segFrames = frames[seg];
|
|
371
381
|
const segDuration = durations[seg];
|
|
372
|
-
const stepTime = segDuration / segFrames;
|
|
382
|
+
const stepTime = segDuration / segFrames;
|
|
373
383
|
for (let f = 0; f < segFrames; f++) {
|
|
374
384
|
const percent = currentTime / totalTime;
|
|
375
385
|
keyframes.push({
|
|
@@ -380,7 +390,6 @@
|
|
|
380
390
|
frameIdx++;
|
|
381
391
|
}
|
|
382
392
|
}
|
|
383
|
-
// 确保最后一帧在 100%
|
|
384
393
|
keyframes.push({
|
|
385
394
|
percent: 1.0,
|
|
386
395
|
url: frameUrls[frameCount - 1]
|
|
@@ -388,13 +397,11 @@
|
|
|
388
397
|
return keyframes;
|
|
389
398
|
}
|
|
390
399
|
|
|
391
|
-
// 生成单个光标的 CSS 声明(不包含动画,用于选择器规则)
|
|
392
400
|
_buildCursorCss(name, cfg) {
|
|
393
401
|
const frameUrls = this._getFrameUrls(cfg);
|
|
394
402
|
const offset = cfg.offset || [0, 0];
|
|
395
403
|
const fallback = cfg.fallback || this.options.fallbackCursor;
|
|
396
404
|
let css = `cursor: url("${frameUrls[0]}") ${offset[0]} ${offset[1]}, ${fallback};`;
|
|
397
|
-
// 如果有动画,附加动画属性
|
|
398
405
|
const hasAnimation = cfg.frames !== undefined && cfg.duration !== undefined &&
|
|
399
406
|
((Array.isArray(cfg.frames) && Array.isArray(cfg.duration)) ||
|
|
400
407
|
(typeof cfg.frames === 'number' && typeof cfg.duration === 'number'));
|
|
@@ -405,7 +412,16 @@
|
|
|
405
412
|
return css;
|
|
406
413
|
}
|
|
407
414
|
|
|
408
|
-
|
|
415
|
+
_simpleHash(str) {
|
|
416
|
+
let hash = 0;
|
|
417
|
+
for (let i = 0; i < str.length; i++) {
|
|
418
|
+
const char = str.charCodeAt(i);
|
|
419
|
+
hash = ((hash << 5) - hash) + char;
|
|
420
|
+
hash |= 0;
|
|
421
|
+
}
|
|
422
|
+
return Math.abs(hash).toString(36);
|
|
423
|
+
}
|
|
424
|
+
|
|
409
425
|
_initDebug() {
|
|
410
426
|
const debugDiv = document.createElement('div');
|
|
411
427
|
debugDiv.className = 'animecursor-debug';
|
|
@@ -428,12 +444,11 @@
|
|
|
428
444
|
let lastCursor = '';
|
|
429
445
|
this._onMouseMove = (e) => {
|
|
430
446
|
const target = document.elementFromPoint(e.clientX, e.clientY);
|
|
431
|
-
let cursorType =
|
|
447
|
+
let cursorType = null;
|
|
432
448
|
if (target) {
|
|
433
449
|
if (target.dataset.cursor && this.cursors[target.dataset.cursor]) {
|
|
434
450
|
cursorType = target.dataset.cursor;
|
|
435
451
|
} else {
|
|
436
|
-
// 检查匹配 tags
|
|
437
452
|
for (const [name, cfg] of Object.entries(this.cursors)) {
|
|
438
453
|
if (cfg.tags && cfg.tags.some(tag => target.matches(tag))) {
|
|
439
454
|
cursorType = name;
|
|
@@ -442,6 +457,11 @@
|
|
|
442
457
|
}
|
|
443
458
|
}
|
|
444
459
|
}
|
|
460
|
+
if (!cursorType && !this.defaultCursorName) {
|
|
461
|
+
cursorType = 'native';
|
|
462
|
+
} else if (!cursorType && this.defaultCursorName) {
|
|
463
|
+
cursorType = this.defaultCursorName;
|
|
464
|
+
}
|
|
445
465
|
if (cursorType !== lastCursor) {
|
|
446
466
|
lastCursor = cursorType;
|
|
447
467
|
debugDiv.textContent = `🎯 ${cursorType} @ (${e.clientX}, ${e.clientY})`;
|
|
@@ -452,10 +472,10 @@
|
|
|
452
472
|
document.addEventListener('mousemove', this._onMouseMove);
|
|
453
473
|
}
|
|
454
474
|
|
|
455
|
-
// 刷新:重新注入样式(用于动态添加新光标等场景)
|
|
456
475
|
refresh() {
|
|
457
476
|
if (this.disabled) return;
|
|
458
477
|
if (this.styleEl) this.styleEl.remove();
|
|
478
|
+
this.combinedRules.clear();
|
|
459
479
|
this._injectStyles();
|
|
460
480
|
if (this.options.debug) {
|
|
461
481
|
if (this.debugEl) this.debugEl.remove();
|
|
@@ -464,7 +484,6 @@
|
|
|
464
484
|
console.log('[AnimeCursor] Refresh complete');
|
|
465
485
|
}
|
|
466
486
|
|
|
467
|
-
// 销毁实例
|
|
468
487
|
destroy() {
|
|
469
488
|
if (this.disabled) return;
|
|
470
489
|
if (this.styleEl) this.styleEl.remove();
|
|
@@ -472,13 +491,11 @@
|
|
|
472
491
|
if (this._onMouseMove) {
|
|
473
492
|
document.removeEventListener('mousemove', this._onMouseMove);
|
|
474
493
|
}
|
|
475
|
-
// 清除全局禁用类
|
|
476
494
|
document.body.classList.remove('animecursor-disabled');
|
|
477
495
|
_instance = null;
|
|
478
496
|
console.log('[AnimeCursor] Destroyed');
|
|
479
497
|
}
|
|
480
498
|
|
|
481
|
-
// 禁用光标动画
|
|
482
499
|
disable() {
|
|
483
500
|
if (this.disabled) return;
|
|
484
501
|
this.disabled = true;
|
|
@@ -486,7 +503,6 @@
|
|
|
486
503
|
if (this.options.debug) console.log('[AnimeCursor] Disabled');
|
|
487
504
|
}
|
|
488
505
|
|
|
489
|
-
// 启用光标动画
|
|
490
506
|
enable() {
|
|
491
507
|
if (!this.disabled) return;
|
|
492
508
|
this.disabled = false;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).AnimeCursor=t()}(this,function(){"use strict";let e=null;return class{static get instance(){return e}static destroy(){return!!e&&(e.destroy(),!0)}static refresh(){return!!e&&(e.refresh(),!0)}static disable(){return!!e&&(e.disable(),!0)}static enable(){return!!e&&(e.enable(),!0)}constructor(t={}){return e?(console.warn("[AnimeCursor] Instance already exists, returning existing one"),e):(this.options={debug:!1,enableTouch:!1,fallbackCursor:"auto",excludeSelectors:"input, textarea, [contenteditable]",...t},this.disabled=!1,this.cursors=this.options.cursors||{},this.options.enableTouch||this.isMouseLikeDevice()?(this.styleEl=null,this.debugEl=null,this._onMouseMove=null,this._validateOptions(),this._preloadImages(),this._checkDomLoad(),void(e=this)):(this.disabled=!0,void(this.options.debug&&console.warn("[AnimeCursor] Touch device detected, cursor animations disabled"))))}isMouseLikeDevice(){return window.matchMedia("(pointer: fine)").matches}_validateOptions(){if(this.disabled)return;if(!this.cursors||0===Object.keys(this.cursors).length)throw new Error("[AnimeCursor] At least one cursor must be defined");let e=!1;for(const[t,r]of Object.entries(this.cursors)){if(!r.image)throw new Error(`[AnimeCursor] Cursor "${t}" missing required setting: image`);if(void 0!==r.frames&&void 0!==r.duration){if(typeof r.frames!==typeof r.duration)console.warn(`[AnimeCursor] Cursor "${t}" has mismatched types for frames and duration, treating as static cursor`),delete r.frames,delete r.duration;else if(Array.isArray(r.frames)&&Array.isArray(r.duration))if(r.frames.length!==r.duration.length)console.warn(`[AnimeCursor] Cursor "${t}" frames and duration arrays have different lengths, treating as static cursor`),delete r.frames,delete r.duration;else{for(let e of r.frames)if(!Number.isInteger(e)||e<=0){console.warn(`[AnimeCursor] Cursor "${t}" frames array contains invalid value, treating as static cursor`),delete r.frames,delete r.duration;break}for(let e of r.duration)if("number"!=typeof e||e<=0){console.warn(`[AnimeCursor] Cursor "${t}" duration array contains invalid value, treating as static cursor`),delete r.frames,delete r.duration;break}}else"number"==typeof r.frames&&"number"==typeof r.duration?(r.frames<=0||r.duration<=0)&&(console.warn(`[AnimeCursor] Cursor "${t}" frames or duration <= 0, treating as static cursor`),delete r.frames,delete r.duration):(console.warn(`[AnimeCursor] Cursor "${t}" frames and duration must be both numbers or both arrays, treating as static cursor`),delete r.frames,delete r.duration)}else void 0===r.frames&&void 0===r.duration||(console.warn(`[AnimeCursor] Cursor "${t}" has only frames or duration defined, treating as static cursor`),delete r.frames,delete r.duration);if(r.tags&&!Array.isArray(r.tags))throw new Error(`[AnimeCursor] Cursor "${t}" tags must be an array`);if(r.default){if(e)throw new Error("[AnimeCursor] Only one default cursor allowed");e=!0}if(r.offset&&(!Array.isArray(r.offset)||2!==r.offset.length))throw new Error(`[AnimeCursor] Cursor "${t}" offset must be [x, y] array`)}
|
|
1
|
+
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).AnimeCursor=t()}(this,function(){"use strict";let e=null;return class{static get instance(){return e}static destroy(){return!!e&&(e.destroy(),!0)}static refresh(){return!!e&&(e.refresh(),!0)}static disable(){return!!e&&(e.disable(),!0)}static enable(){return!!e&&(e.enable(),!0)}constructor(t={}){return e?(console.warn("[AnimeCursor] Instance already exists, returning existing one"),e):(this.options={debug:!1,enableTouch:!1,fallbackCursor:"auto",excludeSelectors:"input, textarea, [contenteditable]",combineAnimations:!1,...t},this.disabled=!1,this.cursors=this.options.cursors||{},this.cursorAnimationStrings={},this.combinedRules=new Map,this.options.enableTouch||this.isMouseLikeDevice()?(this.styleEl=null,this.debugEl=null,this._onMouseMove=null,this._validateOptions(),this._preloadImages(),this._checkDomLoad(),void(e=this)):(this.disabled=!0,void(this.options.debug&&console.warn("[AnimeCursor] Touch device detected, cursor animations disabled"))))}isMouseLikeDevice(){return window.matchMedia("(pointer: fine)").matches}_validateOptions(){if(this.disabled)return;if(!this.cursors||0===Object.keys(this.cursors).length)throw new Error("[AnimeCursor] At least one cursor must be defined");let e=!1;for(const[t,r]of Object.entries(this.cursors)){if(!r.image)throw new Error(`[AnimeCursor] Cursor "${t}" missing required setting: image`);if(void 0!==r.frames&&void 0!==r.duration){if(typeof r.frames!==typeof r.duration)console.warn(`[AnimeCursor] Cursor "${t}" has mismatched types for frames and duration, treating as static cursor`),delete r.frames,delete r.duration;else if(Array.isArray(r.frames)&&Array.isArray(r.duration))if(r.frames.length!==r.duration.length)console.warn(`[AnimeCursor] Cursor "${t}" frames and duration arrays have different lengths, treating as static cursor`),delete r.frames,delete r.duration;else{for(let e of r.frames)if(!Number.isInteger(e)||e<=0){console.warn(`[AnimeCursor] Cursor "${t}" frames array contains invalid value, treating as static cursor`),delete r.frames,delete r.duration;break}for(let e of r.duration)if("number"!=typeof e||e<=0){console.warn(`[AnimeCursor] Cursor "${t}" duration array contains invalid value, treating as static cursor`),delete r.frames,delete r.duration;break}}else"number"==typeof r.frames&&"number"==typeof r.duration?(r.frames<=0||r.duration<=0)&&(console.warn(`[AnimeCursor] Cursor "${t}" frames or duration <= 0, treating as static cursor`),delete r.frames,delete r.duration):(console.warn(`[AnimeCursor] Cursor "${t}" frames and duration must be both numbers or both arrays, treating as static cursor`),delete r.frames,delete r.duration)}else void 0===r.frames&&void 0===r.duration||(console.warn(`[AnimeCursor] Cursor "${t}" has only frames or duration defined, treating as static cursor`),delete r.frames,delete r.duration);if(r.tags&&!Array.isArray(r.tags))throw new Error(`[AnimeCursor] Cursor "${t}" tags must be an array`);if(r.default){if(e)throw new Error("[AnimeCursor] Only one default cursor allowed");e=!0}if(r.offset&&(!Array.isArray(r.offset)||2!==r.offset.length))throw new Error(`[AnimeCursor] Cursor "${t}" offset must be [x, y] array`)}this.defaultCursorName=e?Object.keys(this.cursors).find(e=>this.cursors[e].default):null}_preloadImages(){const e=new Set;for(const t of Object.values(this.cursors)){this._getFrameUrls(t).forEach(t=>e.add(t))}e.forEach(e=>{const t=document.createElement("link");t.rel="preload",t.as="image",t.href=e,e.startsWith("http")&&!e.startsWith(window.location.origin)&&(t.crossOrigin="anonymous"),document.head.appendChild(t)}),this.options.debug&&e.size&&console.info(`[AnimeCursor] Preloaded ${e.size} cursor images`)}_getFrameUrls(e){let t=1;void 0!==e.frames&&(Array.isArray(e.frames)?t=e.frames.reduce((e,t)=>e+t,0):"number"==typeof e.frames&&(t=e.frames));const{image:r}=e;if(1===t)return[r];const{prefix:s,suffix:o,startNum:i,numFormat:n,ext:a}=this._parseImagePattern(r),u=[];for(let e=0;e<t;e++){const t=i+e,r=`${s}${n?this._formatNumber(t,n):t}${o}${a}`;u.push(r)}return u}_parseImagePattern(e){const t=e.match(/\.[^.]+$/),r=t?t[0]:"",s=e.slice(0,-r.length),o=s.match(/(\d+)(?!.*\d)/);if(!o)return{prefix:s+"_",suffix:"",startNum:1,numFormat:null,ext:r};const i=o[0],n=parseInt(i,10),a=i.length;return{prefix:s.slice(0,o.index),suffix:s.slice(o.index+i.length),startNum:n,numFormat:a,ext:r}}_formatNumber(e,t){return String(e).padStart(t,"0")}_checkDomLoad(){const e=()=>{this._injectStyles(),this.options.debug&&this._initDebug(),console.log("[AnimeCursor] Initialization complete")};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}_injectStyles(){if(this.disabled)return;const e=document.createElement("style");e.id="animecursor-styles";let t="";if(this.defaultCursorName){const e=this.cursors[this.defaultCursorName];t+=`* { ${this._buildCursorCss(this.defaultCursorName,e)} }\n`}for(const[e,r]of Object.entries(this.cursors)){const s=`.ac-cursor-${e}`,o=r.offset||[0,0],i=r.fallback||this.options.fallbackCursor,n=this._getFrameUrls(r),a=n.length;let u="";if(void 0!==r.frames&&void 0!==r.duration&&(Array.isArray(r.frames)&&Array.isArray(r.duration)||"number"==typeof r.frames&&"number"==typeof r.duration)&&a>1){const a=`ac_anim_${e}`;let l=`@keyframes ${a} {\n`;const d=this._buildKeyframes(r,n);for(const e of d){let t=(100*e.percent).toFixed(5);1===e.percent&&(t="100");l+=` ${t}% { ${`cursor: url("${e.url}") ${o[0]} ${o[1]}, ${i};`} }\n`}l+="}\n",t+=l;const c=`${a} ${Array.isArray(r.duration)?r.duration.reduce((e,t)=>e+t,0):r.duration}s steps(1) infinite ${r.pingpong?"alternate":""}`;u=c,t+=`${s} { cursor: url("${n[0]}") ${o[0]} ${o[1]}, ${i}; animation: ${c}; }\n`}else t+=`${s} { cursor: url("${n[0]}") ${o[0]} ${o[1]}, ${i}; }\n`;if(this.cursorAnimationStrings[e]=u,r.tags&&r.tags.length){t+=`${r.tags.join(", ")} { ${this._buildCursorCss(e,r)} }\n`}t+=`[data-cursor="${e}"] { ${this._buildCursorCss(e,r)} }\n`}if(this.options.excludeSelectors&&(t+=`${this.options.excludeSelectors} { cursor: text !important; animation: none !important; }\n`),this.options.combineAnimations){const e=document.querySelectorAll("[data-ac-animation]");for(const r of e){const e=r.getAttribute("data-ac-animation");if(!e)continue;let s=this._getCursorTypeForElement(r);if(!s)continue;const o=this.cursorAnimationStrings[s];if(!o)continue;const i=`${s}:${e}`;if(!this.combinedRules.has(i)){const r=`ac-combined-${this._simpleHash(i)}`;t+=`.${r} { animation: ${o}, ${e}; }\n`,this.combinedRules.set(i,r)}const n=this.combinedRules.get(i);r.classList.add(n)}}t+="body.animecursor-disabled * { cursor: auto !important; animation: none !important; }\n",e.textContent=t,document.head.appendChild(e),this.styleEl=e}_getCursorTypeForElement(e){if(e.dataset.cursor&&this.cursors[e.dataset.cursor])return e.dataset.cursor;for(const[t,r]of Object.entries(this.cursors))if(r.tags&&r.tags.some(t=>e.matches(t)))return t;return this.defaultCursorName}_buildKeyframes(e,t){let r=e.frames,s=e.duration;const o=t.length;if("number"==typeof r){const e=s/r;r=new Array(r).fill(1),s=new Array(r.length).fill(e)}const i=[];let n=s.reduce((e,t)=>e+t,0),a=0,u=0;for(let e=0;e<r.length;e++){const o=r[e],l=s[e]/o;for(let e=0;e<o;e++){const e=a/n;i.push({percent:e,url:t[u]}),a+=l,u++}}return i.push({percent:1,url:t[o-1]}),i}_buildCursorCss(e,t){const r=this._getFrameUrls(t),s=t.offset||[0,0],o=t.fallback||this.options.fallbackCursor;let i=`cursor: url("${r[0]}") ${s[0]} ${s[1]}, ${o};`;if(void 0!==t.frames&&void 0!==t.duration&&(Array.isArray(t.frames)&&Array.isArray(t.duration)||"number"==typeof t.frames&&"number"==typeof t.duration)&&r.length>1){i+=` animation: ac_anim_${e} ${Array.isArray(t.duration)?t.duration.reduce((e,t)=>e+t,0):t.duration}s steps(1) infinite ${t.pingpong?"alternate":""};`}return i}_simpleHash(e){let t=0;for(let r=0;r<e.length;r++){t=(t<<5)-t+e.charCodeAt(r),t|=0}return Math.abs(t).toString(36)}_initDebug(){const e=document.createElement("div");e.className="animecursor-debug",e.style.cssText="\n position: fixed;\n top: 0;\n left: 0;\n background: rgba(0,0,0,0.7);\n color: #0f0;\n padding: 4px 8px;\n font-family: monospace;\n font-size: 12px;\n z-index: 2147483647;\n pointer-events: none;\n white-space: nowrap;\n ",document.body.appendChild(e),this.debugEl=e;let t="";this._onMouseMove=r=>{const s=document.elementFromPoint(r.clientX,r.clientY);let o=null;if(s)if(s.dataset.cursor&&this.cursors[s.dataset.cursor])o=s.dataset.cursor;else for(const[e,t]of Object.entries(this.cursors))if(t.tags&&t.tags.some(e=>s.matches(e))){o=e;break}o||this.defaultCursorName?!o&&this.defaultCursorName&&(o=this.defaultCursorName):o="native",o!==t?(t=o,e.textContent=`🎯 ${o} @ (${r.clientX}, ${r.clientY})`):e.textContent=`🎯 ${o} @ (${r.clientX}, ${r.clientY})`},document.addEventListener("mousemove",this._onMouseMove)}refresh(){this.disabled||(this.styleEl&&this.styleEl.remove(),this.combinedRules.clear(),this._injectStyles(),this.options.debug&&(this.debugEl&&this.debugEl.remove(),this._initDebug()),console.log("[AnimeCursor] Refresh complete"))}destroy(){this.disabled||(this.styleEl&&this.styleEl.remove(),this.debugEl&&this.debugEl.remove(),this._onMouseMove&&document.removeEventListener("mousemove",this._onMouseMove),document.body.classList.remove("animecursor-disabled"),e=null,console.log("[AnimeCursor] Destroyed"))}disable(){this.disabled||(this.disabled=!0,document.body.classList.add("animecursor-disabled"),this.options.debug&&console.log("[AnimeCursor] Disabled"))}enable(){this.disabled&&(this.disabled=!1,document.body.classList.remove("animecursor-disabled"),this.options.debug&&console.log("[AnimeCursor] Enabled"))}}});
|