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 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.0.0/dist/anime-cursor.umd.min.js"></script>
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.0.0/dist/anime-cursor.umd.min.js"></script>
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` 自动应用光标样式:
@@ -1,5 +1,5 @@
1
1
  // AnimeCursor by github@ShuninYu
2
- // v2.0.0
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', // Fallback cursor type (auto, pointer, etc.)
53
- excludeSelectors: 'input, textarea, [contenteditable]', // Exclude native cursor elements
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
- if (!hasDefault) {
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
- // 注入所有 CSS 规则
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
- const defaultCfg = this.cursors[this.defaultCursorName];
280
- const defaultCursorDef = this._buildCursorCss(this.defaultCursorName, defaultCfg);
281
- css += `* { ${defaultCursorDef} }\n`;
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
- // tags 和 data-cursor 生成选择器规则
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
- // 根据 frames/duration 配置构建关键帧列表(百分比和对应图片 URL)
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 = this.defaultCursorName;
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;
@@ -5,7 +5,7 @@
5
5
  })(this, (function () { 'use strict';
6
6
 
7
7
  // AnimeCursor by github@ShuninYu
8
- // v2.0.0
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', // Fallback cursor type (auto, pointer, etc.)
59
- excludeSelectors: 'input, textarea, [contenteditable]', // Exclude native cursor elements
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
- if (!hasDefault) {
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
- // 注入所有 CSS 规则
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
- const defaultCfg = this.cursors[this.defaultCursorName];
286
- const defaultCursorDef = this._buildCursorCss(this.defaultCursorName, defaultCfg);
287
- css += `* { ${defaultCursorDef} }\n`;
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
- // tags 和 data-cursor 生成选择器规则
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
- // 根据 frames/duration 配置构建关键帧列表(百分比和对应图片 URL)
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 = this.defaultCursorName;
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`)}if(!e)throw new Error("[AnimeCursor] A default cursor (default: true) must be defined");this.defaultCursorName=Object.keys(this.cursors).find(e=>this.cursors[e].default)}_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:n,numFormat:i,ext:a}=this._parseImagePattern(r),u=[];for(let e=0;e<t;e++){const t=n+e,r=`${s}${i?this._formatNumber(t,i):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 n=o[0],i=parseInt(n,10),a=n.length;return{prefix:s.slice(0,o.index),suffix:s.slice(o.index+n.length),startNum:i,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="";const r=this.cursors[this.defaultCursorName];t+=`* { ${this._buildCursorCss(this.defaultCursorName,r)} }\n`;for(const[e,r]of Object.entries(this.cursors)){const s=`.ac-cursor-${e}`,o=r.offset||[0,0],n=r.fallback||this.options.fallbackCursor,i=this._getFrameUrls(r),a=i.length;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 u=`@keyframes ${a} {\n`;const l=this._buildKeyframes(r,i);for(const e of l){let t=(100*e.percent).toFixed(5);1===e.percent&&(t="100");u+=` ${t}% { ${`cursor: url("${e.url}") ${o[0]} ${o[1]}, ${n};`} }\n`}u+="}\n",t+=u;const d=`${a} ${Array.isArray(r.duration)?r.duration.reduce((e,t)=>e+t,0):r.duration}s steps(1) infinite ${r.pingpong?"alternate":""}`;t+=`${s} { cursor: url("${i[0]}") ${o[0]} ${o[1]}, ${n}; animation: ${d}; }\n`}else t+=`${s} { cursor: url("${i[0]}") ${o[0]} ${o[1]}, ${n}; }\n`;if(r.tags&&r.tags.length){t+=`${r.tags.join(", ")} { ${this._buildCursorCss(e,r)} }\n`}t+=`[data-cursor="${e}"] { ${this._buildCursorCss(e,r)} }\n`}this.options.excludeSelectors&&(t+=`${this.options.excludeSelectors} { cursor: text !important; animation: none !important; }\n`),t+="body.animecursor-disabled * { cursor: auto !important; animation: none !important; }\n",e.textContent=t,document.head.appendChild(e),this.styleEl=e}_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 n=[];let i=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/i;n.push({percent:e,url:t[u]}),a+=l,u++}}return n.push({percent:1,url:t[o-1]}),n}_buildCursorCss(e,t){const r=this._getFrameUrls(t),s=t.offset||[0,0],o=t.fallback||this.options.fallbackCursor;let n=`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){n+=` 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 n}_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=this.defaultCursorName;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!==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._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"))}}});
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"))}}});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anime-cursor",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "A lightweight JavaScript library for animated custom cursors",
5
5
  "main": "dist/anime-cursor.umd.js",
6
6
  "module": "dist/anime-cursor.esm.js",